From 56f44441516040a150329361de607ee72e9996ac Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Fri, 2 Jan 2026 01:09:13 +0800 Subject: [PATCH 01/22] feat: new ui --- Cargo.toml | 8 +- assets/96x96.png | Bin 0 -> 18903 bytes assets/lm_320x240.png | Bin 0 -> 91508 bytes src/app.rs | 95 ++++--- src/main.rs | 213 +++++++++------ src/ui.rs | 583 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 750 insertions(+), 149 deletions(-) create mode 100755 assets/96x96.png create mode 100755 assets/lm_320x240.png diff --git a/Cargo.toml b/Cargo.toml index 2b65f8d..ba16c2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ serde_json = "1.0" rmp-serde = "1" esp32-nimble = "0.11.1" -# embedded-websocket = { version = "0.9.4" } + +# UI libraries embedded-graphics = "0.8.1" embedded-text = "0.7.2" -# async-io = "2.4.0" - u8g2-fonts = { version = "0.6.0", features = ["embedded_graphics_textstyle"] } tinygif = "0.0.4" -# futures-lite = "2.6.0" +image = { version = "0.25.6", default-features = false, features = ["png"] } + futures-util = { version = "0.3.31", features = ["sink"] } # futures-sink = "0.3.31" diff --git a/assets/96x96.png b/assets/96x96.png new file mode 100755 index 0000000000000000000000000000000000000000..9e44b4cfe1acc8e1bfdcb570376ee51db7a1f46d GIT binary patch literal 18903 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
rZEbDw{nj9HmP!zQ(n~E~o9g11XUMPatwRE4 zimBHy*}s~oGF|(#igeuJ9lz9WzLUJv?qQm(!r6&8gXb|x(4|2~xH)QZO$9_07>4CA zpWK%$Z29z1y;_bWnL4^oRytISfx*FmyVJqBc7HCgI|rhu-+p~l1|r$&SGs?cJz2<~ zB(+~jh`$ux6Y2FVE`VW}(n-0-qN~W%E$?OUUv5!BoMlWp-Q)oA?r9r^{<|TEJ+=zx z6N4{gvIfRt0_YQLW`*&{jzUX!2L3=-kNBBIW;$yJ9wy3g9YrBv$-e&Hy}@pl69ui} zmy3d?gUo5F>7S}?_RdJhoXDTlThh@9*+)$x3R6K@E=6akulq7~J{ zjdztH2FwK&j!q;{lRmjq%SM)_sN2?u`{(i^BXg~rMVf`g?HXLh-sPx0|DmG@1ZW4prN4wM@GbHs!G5#DV9p? z*s+7Ux;n!0e16@~X>MxqcLl1df~IQ}3*w|KVKqs)uZZIWilvfRbPogog;J?RU*9Rx z=>`S|Ph%J+eWy<0I1blecRhxw`wKlo!$V?WKO80!jgrk~IdS3yqoboZj!RQxBXj1? zWzU|sS+saDk#LxDsYECgA{vP>JTio3+qAZ}39NZ^@DMJqJ#i!SS5??}`b?G*souTZ z;I5#eef^)lc2{$gK=7ZR{WGt=`U+E1Q(SrF)y$hWPrg$u%CvXP!F8dvwUresR#9J{ zLh)kk<$?B)6*{K&YuEXR z@*p0Iv3%KbmM&Q)z7JL->@t}t{_|J=!R|M9arEdB7A{=Ckt2uLxpN0C%`L=ZamL0* zId=T0?1&6bRmC?aZA zVBsQ;963Z=dpoK66!obT^Lu)69hU_Q7IEpNS5UDk*p@}1kY{Xc6jg@1oRlAF&~=?q zFhoOR6HSdx%$+ljr=EF2C}jBoW>pc}W%YmZM+}uh$78i@#(mPlz7O-uL zXf#Hlkf%^6ijV9BgCMsz4Gj-+=FABBe1SkfoGCgoa)y2T_R`+oL9tk1?wq-l$|a(a zD4kth=(`5(hDzzj`$wEU6_+m(t$Wju9}0>sx*v<&{@<_~%W} z=(h~loHwtNK)@uM%hA!^K`xhNWMqVvmKMgx$2mPPz`_L!IC}Ib8#Zj9zyCA`4jzz) zk0>apBDTu~ni`w<@|VBFUElf^D^@ILa$f@SaCCr4gvJ?^8;@9{8C-@2xU%br7oNZ#Bqv6+Ie?42a zY~h(_p5X&Ge}F@W4smAm3@t4!OifPl^2^(~^|ssCwskA()~yHND_{AtI6=&HB>`ky z`~3OceEsWR;r{y{;A0>C5Klj|4S;ki#Y-=}#M-rM$xKbNXV2Tbxn~czrX%dhmdVfU zWO8zf`uZfLQkhUl`1$j>0`;kS{|x4VfgvU)C#w3arax6&%pjixk>g2FFTeWu*@5|P zm39(wV%RIl*{WNOrWRUf#>#qaEi<`jR(c|lBrH#L42Q$mmiVGmsaPTwk7HFTL?RKf z?bUTDmrCUFd7&r3BY?R4!4JOA;NT#(?GTBGh4Z)e?4uSRAHtu4I%+AenO z+RcZWVjL=39M{9-^LeSD6e5v`{AimHA1GpLXd#~$A83%Bp262%KV=^k+0WUeI2VXV z&qcU%!P(`+E9FE>64Fnb*-DvOO&5b=Hj%2=K9T*t$qq^@N#H@Itz5B!{{GXP895`q zE))zhHaQ87OWe>IC|AU0<$%fILx*V!=!{ovDrX1R%>>2oU-jO*kn5zPhIgsQ?~ngr zHN(e3&9m=PO?606gDClTb{+qppo+|Qhnh08iQu~YKh}pTxOg%M8UO$Q07*qoM6N<$ Ef?Z^=_W%F@ literal 0 HcmV?d00001 diff --git a/assets/lm_320x240.png b/assets/lm_320x240.png new file mode 100755 index 0000000000000000000000000000000000000000..45eace3351d3ea7dba1a638a967f0ac6a5e2301c GIT binary patch literal 91508 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 mut server: Server, player_tx: audio::PlayerTx, mut evt_rx: EventRx, - backgroud_buffer: Option<&'d [u8]>, + mut gui: crate::ui::ChatUI, ) -> anyhow::Result<()> { #[derive(PartialEq, Eq)] enum State { @@ -170,10 +170,9 @@ pub async fn main_work<'d>( Idle, } - let mut gui = crate::ui::UI::new(backgroud_buffer, crate::boards::flush_display)?; - - gui.state = "Idle".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Idle".to_string()); + gui.set_text("".to_string()); + gui.flush()?; let mut state = State::Idle; @@ -205,8 +204,8 @@ pub async fn main_work<'d>( if state == State::Listening { state = State::Idle; - gui.state = "Idle".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Idle".to_string()); + gui.flush()?; server.close().await?; } else { let hello_notify = Arc::new(tokio::sync::Notify::new()); @@ -225,8 +224,8 @@ pub async fn main_work<'d>( log::info!("Hello response received"); state = State::Listening; - gui.state = "Listening...".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Listening...".to_string()); + gui.flush()?; } } Event::Event(Event::K0_) => { @@ -234,8 +233,8 @@ pub async fn main_work<'d>( { allow_interrupt = !allow_interrupt; log::info!("Set allow_interrupt to {}", allow_interrupt); - gui.state = format!("Interrupt: {}", allow_interrupt); - gui.display_flush().unwrap(); + gui.set_state(format!("Interrupt: {}", allow_interrupt)); + gui.flush()?; } } Event::Event(Event::VOL_UP) => { @@ -247,8 +246,8 @@ pub async fn main_work<'d>( .send(AudioEvent::VolSet(vol)) .map_err(|e| anyhow::anyhow!("Error sending volume set: {e:?}"))?; log::info!("Volume set to {}", vol); - gui.state = format!("Volume: {}", vol); - gui.display_flush().unwrap(); + gui.set_state(format!("Volume: {}", vol)); + gui.flush()?; } Event::Event(Event::VOL_DOWN) => { vol -= 1; @@ -259,8 +258,8 @@ pub async fn main_work<'d>( .send(AudioEvent::VolSet(vol)) .map_err(|e| anyhow::anyhow!("Error sending volume set: {e:?}"))?; log::info!("Volume set to {}", vol); - gui.state = format!("Volume: {}", vol); - gui.display_flush().unwrap(); + gui.set_state(format!("Volume: {}", vol)); + gui.flush()?; } Event::Event(Event::VOL_SWITCH) => { vol -= 1; @@ -271,16 +270,16 @@ pub async fn main_work<'d>( .send(AudioEvent::VolSet(vol)) .map_err(|e| anyhow::anyhow!("Error sending volume set: {e:?}"))?; log::info!("Volume set to {}", vol); - gui.state = format!("Volume: {}", vol); - gui.display_flush().unwrap(); + gui.set_state(format!("Volume: {}", vol)); + gui.flush()?; } Event::Event(Event::YES | Event::K1) => {} Event::Event(Event::IDLE) => { log::info!("Received idle event"); if state == State::Listening { state = State::Idle; - gui.state = "Idle".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Idle".to_string()); + gui.flush()?; server.close().await?; } } @@ -319,8 +318,8 @@ pub async fn main_work<'d>( if submit_audio > 0.6 { state = State::Listening; - gui.state = "Listening...".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Listening...".to_string()); + gui.flush()?; server.reconnect_with_retry(3).await?; @@ -377,8 +376,8 @@ pub async fn main_work<'d>( start_submit = false; wait_notify = false; state = State::Waiting; - gui.state = "Waiting...".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Waiting...".to_string()); + gui.flush()?; } } Event::MicInterruptWaitTimeout => { @@ -405,20 +404,20 @@ pub async fn main_work<'d>( start_submit = false; wait_notify = false; state = State::Waiting; - gui.state = "Waiting...".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Waiting...".to_string()); + gui.flush()?; } Event::ServerEvent(ServerEvent::ASR { text }) => { log::info!("Received ASR: {:?}", text); state = State::Speaking; - gui.state = "ASR".to_string(); - gui.text = text.trim().to_string(); - gui.display_flush().unwrap(); + gui.set_state("ASR".to_string()); + gui.set_asr(text.trim().to_string()); + gui.flush()?; } Event::ServerEvent(ServerEvent::Action { action }) => { log::info!("Received action"); - gui.state = format!("Action: {}", action); - gui.display_flush().unwrap(); + gui.set_state(format!("Action: {}", action)); + gui.flush()?; } Event::ServerEvent(ServerEvent::StartAudio { text }) => { start_audio = true; @@ -427,9 +426,9 @@ pub async fn main_work<'d>( continue; } log::info!("Received audio start: {:?}", text); - gui.state = format!("[{:.2}x]|Speaking...", speed); - gui.text = text.trim().to_string(); - gui.display_flush().unwrap(); + gui.set_state(format!("[{:.2}x]|Speaking...", speed)); + gui.set_text(text.trim().to_string()); + gui.flush()?; player_tx .send(AudioEvent::StartSpeech) .map_err(|e| anyhow::anyhow!("Error sending start: {e:?}"))?; @@ -452,8 +451,8 @@ pub async fn main_work<'d>( if speed < SPEED_LIMIT { if let Err(e) = player_tx.send(AudioEvent::SpeechChunk(data)) { log::error!("Error sending audio chunk: {:?}", e); - gui.state = "Error on audio chunk".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Error on audio chunk".to_string()); + gui.flush().unwrap(); } } else { let data_ = unsafe { @@ -480,8 +479,8 @@ pub async fn main_work<'d>( if speed < SPEED_LIMIT { if let Err(e) = player_tx.send(AudioEvent::SpeechChunki16(data)) { log::error!("Error sending audio chunk: {:?}", e); - gui.state = "Error on audio chunk".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Error on audio chunk".to_string()); + gui.flush().unwrap(); } } else { recv_audio_buffer.extend_from_slice(&data); @@ -500,16 +499,16 @@ pub async fn main_work<'d>( if recv_audio_buffer.len() > 0 { if let Err(e) = player_tx.send(AudioEvent::SpeechChunki16(recv_audio_buffer)) { log::error!("Error sending audio chunk: {:?}", e); - gui.state = "Error on audio chunk".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Error on audio chunk".to_string()); + gui.flush().unwrap(); } recv_audio_buffer = Vec::with_capacity(8192); } if let Err(e) = player_tx.send(AudioEvent::EndSpeech(notify.clone())) { log::error!("Error sending audio chunk: {:?}", e); - gui.state = "Error on audio chunk".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Error on audio chunk".to_string()); + gui.flush().unwrap(); } if need_compute { @@ -527,8 +526,8 @@ pub async fn main_work<'d>( Event::ServerEvent(ServerEvent::EndResponse) => { log::info!("Received request end"); state = State::Listening; - gui.state = "Listening...".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Listening...".to_string()); + gui.flush().unwrap(); recv_audio_buffer.clear(); } Event::ServerEvent(ServerEvent::HelloStart) => { @@ -546,8 +545,8 @@ pub async fn main_work<'d>( if !init_hello { if let Err(_) = player_tx.send(AudioEvent::SetHello(hello_wav)) { log::error!("Error sending hello end"); - gui.state = "Error on hello end".to_string(); - gui.display_flush().unwrap(); + gui.set_state("Error on hello end".to_string()); + gui.flush().unwrap(); } hello_wav = Vec::with_capacity(1024 * 30); init_hello = true; @@ -561,9 +560,9 @@ pub async fn main_work<'d>( init_hello = false; server = Server::new(server.id, url).await?; state = State::Idle; - gui.state = "Idle".to_string(); - gui.text = format!("Server URL updated:\n{}", server.url); - gui.display_flush().unwrap(); + gui.set_state("Idle".to_string()); + gui.set_text(format!("Server URL updated:\n{}", server.url)); + gui.flush().unwrap(); } } } diff --git a/src/main.rs b/src/main.rs index 0ae9d0d..c1c9031 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,67 @@ struct Setting { pass: String, server_url: String, background_gif: (Vec, bool), // (data, ended) + state: u8, // if 1, enter setup mode +} + +impl Setting { + fn load_from_nvs(nvs: &esp_idf_svc::nvs::EspDefaultNvs) -> anyhow::Result { + let mut str_buf = [0; 128]; + + let ssid = nvs + .get_str("ssid", &mut str_buf) + .map_err(|e| log::error!("Failed to get ssid: {:?}", e)) + .ok() + .flatten() + .unwrap_or_default() + .to_string(); + + let pass = nvs + .get_str("pass", &mut str_buf) + .map_err(|e| log::error!("Failed to get pass: {:?}", e)) + .ok() + .flatten() + .unwrap_or_default() + .to_string(); + + static DEFAULT_SERVER_URL: Option<&str> = std::option_env!("DEFAULT_SERVER_URL"); + log::info!("DEFAULT_SERVER_URL: {:?}", DEFAULT_SERVER_URL); + + let server_url = nvs + .get_str("server_url", &mut str_buf) + .map_err(|e| log::error!("Failed to get server_url: {:?}", e)) + .ok() + .flatten() + .or(DEFAULT_SERVER_URL) + .unwrap_or_default() + .to_string(); + + let background_gif = if nvs.contains("background_gif")? { + let mut gif_buf = vec![0; 1024 * 1024]; + nvs.get_blob("background_gif", &mut gif_buf)? + .unwrap_or(ui::DEFAULT_BACKGROUND) + .to_vec() + } else { + ui::DEFAULT_BACKGROUND.to_vec() + }; + + let state = nvs.get_u8("state")?.unwrap_or(0); + + Ok(Setting { + ssid, + pass, + server_url, + background_gif: (background_gif.to_vec(), false), + state, + }) + } + + fn need_init(&self) -> bool { + self.state == 1 + || self.ssid.is_empty() + || self.pass.is_empty() + || self.server_url.is_empty() + } } fn main() -> anyhow::Result<()> { @@ -32,55 +93,13 @@ fn main() -> anyhow::Result<()> { let partition = esp_idf_svc::nvs::EspDefaultNvsPartition::take()?; let nvs = esp_idf_svc::nvs::EspDefaultNvs::new(partition, "setting", true)?; - let state = nvs.get_u8("state").ok().flatten().unwrap_or(0); - - let mut str_buf = [0; 128]; - let ssid = nvs - .get_str("ssid", &mut str_buf) - .map_err(|e| log::error!("Failed to get ssid: {:?}", e)) - .ok() - .flatten() - .unwrap_or_default() - .to_string(); - - let pass = nvs - .get_str("pass", &mut str_buf) - .map_err(|e| log::error!("Failed to get pass: {:?}", e)) - .ok() - .flatten() - .unwrap_or_default() - .to_string(); - - static DEFAULT_SERVER_URL: Option<&str> = std::option_env!("DEFAULT_SERVER_URL"); - log::info!("DEFAULT_SERVER_URL: {:?}", DEFAULT_SERVER_URL); - - let mut server_url = nvs - .get_str("server_url", &mut str_buf) - .map_err(|e| log::error!("Failed to get server_url: {:?}", e)) - .ok() - .flatten() - .or(DEFAULT_SERVER_URL) - .unwrap_or_default() - .to_string(); - - // 1MB buffer for GIF - let has_bg = nvs.contains("background_gif").unwrap_or(false); - let mut gif_buf = if has_bg { - vec![0; 1024 * 1024] - } else { - Vec::new() - }; - - let background_gif = nvs - .get_blob("background_gif", &mut gif_buf)? - .unwrap_or(ui::DEFAULT_BACKGROUND); - - log::info!("SSID: {:?}", ssid); - log::info!("PASS: {:?}", pass); - log::info!("Server URL: {:?}", server_url); - + let mut setting = Setting::load_from_nvs(&nvs)?; nvs.set_u8("state", 0).unwrap(); + log::info!("SSID: {:?}", setting.ssid); + log::info!("PASS: {:?}", setting.pass); + log::info!("Server URL: {:?}", setting.server_url); + log_heap(); let (evt_tx, mut evt_rx) = tokio::sync::mpsc::channel(64); @@ -88,7 +107,25 @@ fn main() -> anyhow::Result<()> { crate::start_hal!(peripherals, evt_tx); - let _ = ui::backgroud(&background_gif, boards::flush_display); + let start_ui = if setting.background_gif.0.is_empty() { + log::info!("No background GIF found, using default start UI"); + ui::StartUI { + flush_fn: boards::flush_display, + display_target: ui::new_display_target(), + } + } else { + // ui::StartUI::new_with_gif( + // ui::new_display_target(), + // boards::flush_display, + // &setting.background_gif.0, + // )? + ui::StartUI::new_with_png( + ui::new_display_target(), + boards::flush_display, + ui::LM_PNG, + 3_000, + )? + }; // Configures the button let mut button = esp_idf_svc::hal::gpio::PinDriver::input(peripherals.pins.gpio0)?; @@ -99,8 +136,6 @@ fn main() -> anyhow::Result<()> { .enable_all() .build()?; - let mut gui = ui::UI::new(None, boards::flush_display).unwrap(); - log_heap(); #[cfg(feature = "extra_server")] @@ -125,29 +160,28 @@ fn main() -> anyhow::Result<()> { std::thread::sleep(std::time::Duration::from_millis(2000)); } - let need_init = { - button.is_low() || state == 1 || ssid.is_empty() || pass.is_empty() || server_url.is_empty() - }; + let need_init = button.is_low() || setting.need_init(); + if need_init { - gif_buf.clear(); - let setting = Arc::new(Mutex::new(( - Setting { - ssid, - pass, - server_url, - background_gif: (gif_buf, false), // 1MB - }, - nvs, - ))); + let mut config_ui = ui::new_config_ui(start_ui, "https://echokit.dev/setup/")?; + + let esp_wifi = esp_idf_svc::wifi::EspWifi::new(peripherals.modem, sysloop, None)?; + let mac = esp_wifi.sta_netif().get_mac()?; + let dev_id = format!( + "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + ); + + setting.background_gif.0.clear(); + let setting = Arc::new(Mutex::new((setting, nvs))); let ble_addr = bt::bt(setting.clone(), evt_tx).unwrap(); log_heap(); let version = env!("CARGO_PKG_VERSION"); - gui.state = "Please setup device by bt".to_string(); - gui.text = format!("Goto https://echokit.dev/setup/ to set up the device.\nDevice Name: EchoKit-{}\nVersion: {}", ble_addr, version); - gui.display_qrcode("https://echokit.dev/setup/").unwrap(); + config_ui.set_info( format!("Goto https://echokit.dev/setup/ to set up the device.\nDevice Name: EchoKit-{}\nVersion: {}", ble_addr, version)); + config_ui.flush()?; #[cfg(feature = "boards")] { @@ -179,8 +213,8 @@ fn main() -> anyhow::Result<()> { { let mut setting = setting.lock().unwrap(); if setting.0.background_gif.1 { - gui.text = "Testing background GIF...".to_string(); - gui.display_flush().unwrap(); + config_ui.set_info("Testing background GIF...".to_string()); + config_ui.flush()?; let mut new_gif = Vec::new(); std::mem::swap(&mut setting.0.background_gif.0, &mut new_gif); @@ -188,8 +222,8 @@ fn main() -> anyhow::Result<()> { let _ = ui::backgroud(&new_gif, boards::flush_display); log::info!("Background GIF set from NVS"); - gui.text = "Background GIF set OK".to_string(); - gui.display_flush().unwrap(); + config_ui.set_info("Background GIF set OK".to_string()); + config_ui.flush()?; setting .1 @@ -203,15 +237,21 @@ fn main() -> anyhow::Result<()> { unsafe { esp_idf_svc::sys::esp_restart() } } - gui.state = "Connecting to wifi...".to_string(); - gui.text.clear(); - gui.display_flush().unwrap(); + let mut chat_ui = ui::new_chat_ui(start_ui)?; + + chat_ui.set_state("Connecting to wifi...".to_string()); + chat_ui.flush()?; - let _wifi = network::wifi(&ssid, &pass, peripherals.modem, sysloop.clone()); + let _wifi = network::wifi( + &setting.ssid, + &setting.pass, + peripherals.modem, + sysloop.clone(), + ); if _wifi.is_err() { - gui.state = "Failed to connect to wifi".to_string(); - gui.text = "Press K0 to open settings".to_string(); - gui.display_flush().unwrap(); + chat_ui.set_state("Failed to connect to wifi".to_string()); + chat_ui.set_text("Press K0 to open settings".to_string()); + chat_ui.flush()?; b.block_on(button.wait_for_falling_edge()).unwrap(); nvs.set_u8("state", 1).unwrap(); unsafe { esp_idf_svc::sys::esp_restart() } @@ -226,18 +266,21 @@ fn main() -> anyhow::Result<()> { mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] ); - gui.state = "Connecting to server...".to_string(); - gui.text.clear(); - gui.display_flush().unwrap(); + chat_ui.set_state("Connecting to server...".to_string()); + chat_ui.set_text("".to_string()); + chat_ui.flush()?; log_heap(); - gui.state = "Failed to connect to server".to_string(); - gui.text = format!("Please check your server URL: {server_url}\nPress K0 to open settings"); - let server = b.block_on(ws::Server::new(dev_id, server_url)); + chat_ui.set_state("Failed to connect to server".to_string()); + chat_ui.set_text(format!( + "Please check your server URL: {}\nPress K0 to open settings", + setting.server_url + )); + let server = b.block_on(ws::Server::new(dev_id, setting.server_url)); if server.is_err() { log::info!("Failed to connect to server: {:?}", server.err()); - gui.display_flush().unwrap(); + chat_ui.flush()?; b.block_on(button.wait_for_falling_edge()).unwrap(); nvs.set_u8("state", 1).unwrap(); unsafe { esp_idf_svc::sys::esp_restart() } @@ -247,7 +290,7 @@ fn main() -> anyhow::Result<()> { crate::start_audio_workers!(peripherals, rx1, evt_tx.clone(), &b); - let ws_task = app::main_work(server, tx1, evt_rx, Some(background_gif)); + let ws_task = app::main_work(server, tx1, evt_rx, chat_ui); b.spawn(async move { loop { diff --git a/src/ui.rs b/src/ui.rs index 71b1664..e44dc66 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,5 @@ +use std::u8; + use embedded_graphics::{ framebuffer::{buffer_size, Framebuffer}, image::GetPixel, @@ -6,7 +8,7 @@ use embedded_graphics::{ Rgb565, }, prelude::*, - primitives::{PrimitiveStyleBuilder, Rectangle}, + primitives::{PrimitiveStyle, PrimitiveStyleBuilder, Rectangle}, text::{ renderer::{CharacterStyle, TextRenderer}, Alignment, Text, @@ -17,10 +19,14 @@ use u8g2_fonts::U8g2TextStyle; pub type ColorFormat = Rgb565; -pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/echokit.gif"); +// pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/echokit.gif"); +pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/ht.gif"); use crate::boards::{DISPLAY_HEIGHT, DISPLAY_WIDTH}; +pub const LM_PNG: &[u8] = include_bytes!("../assets/lm_320x240.png"); +pub const AVATAR_PNG: &[u8] = include_bytes!("../assets/96x96.png"); + pub type FlushDisplayFn = fn(color_data: &[u8], x_start: i32, y_start: i32, x_end: i32, y_end: i32) -> i32; @@ -37,6 +43,32 @@ pub fn backgroud(gif: &[u8], f: FlushDisplayFn) -> Result<(), std::convert::Infa { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, >::new()); + let ht = image::ImageReader::with_format(std::io::Cursor::new(LM_PNG), image::ImageFormat::Png); + let img = ht.decode().unwrap().to_rgb8(); + + let p = img + .pixels() + .map(|p| { + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ) + }) + .zip(display.bounding_box().points()) + .map(|(color, point)| Pixel(point, color)); + + p.draw(display.as_mut()).unwrap(); + f( + display.data(), + 0, + 0, + DISPLAY_WIDTH as _, + DISPLAY_HEIGHT as _, + ); + + std::thread::sleep(std::time::Duration::from_millis(30 * 1000)); + display.clear(ColorFormat::WHITE)?; for frame in image.frames() { @@ -136,6 +168,15 @@ impl CharacterStyle for MyTextStyle { } } +type DisplayTarget = Framebuffer< + ColorFormat, + RawU16, + LittleEndian, + DISPLAY_WIDTH, + DISPLAY_HEIGHT, + { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, +>; + pub struct UI { pub state: String, state_area: Rectangle, @@ -144,16 +185,7 @@ pub struct UI { text_area: Rectangle, text_background: Vec>, - display: Box< - Framebuffer< - ColorFormat, - RawU16, - LittleEndian, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, - >, - >, + display: Box, flush_fn: FlushDisplayFn, } @@ -473,3 +505,530 @@ impl UI { Ok(()) } } + +pub struct DisplayArea { + area: Rectangle, + background: Vec>, + text: String, + render_fn: fn(&DisplayArea, &mut DisplayTarget) -> anyhow::Result<()>, +} + +impl DisplayArea { + pub fn new_text_area( + area: Rectangle, + background: Vec>, + text: String, + render_fn: fn(&DisplayArea, &mut DisplayTarget) -> anyhow::Result<()>, + ) -> Self { + Self { + area, + background, + text, + render_fn, + } + } +} + +pub fn get_background_pixels( + display: &DisplayTarget, + area: Rectangle, + background_style: PrimitiveStyle, + alpha: f32, +) -> Vec> { + area.into_styled(background_style) + .pixels() + .map(|p| { + if let Some(color) = display.pixel(p.0) { + Pixel(p.0, alpha_mix(color, p.1, alpha)) + } else { + p + } + }) + .collect() +} + +pub fn new_display_target() -> Box { + let mut display_target = Box::new(Framebuffer::< + ColorFormat, + _, + LittleEndian, + DISPLAY_WIDTH, + DISPLAY_HEIGHT, + { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, + >::new()); + + display_target.clear(ColorFormat::WHITE).unwrap(); + + display_target +} + +pub struct ImageArea { + area: Rectangle, + image_data: Vec>, + render_fn: fn(&ImageArea, &mut DisplayTarget) -> anyhow::Result<()>, +} + +impl ImageArea { + pub fn new_from_color(area: Rectangle, color: ColorFormat) -> anyhow::Result { + let pixels: Vec> = + area.points().map(|point| Pixel(point, color)).collect(); + + Ok(Self { + area, + image_data: pixels, + render_fn: Self::default_render, + }) + } + + pub fn new_from_png(area: Rectangle, png_data: &[u8]) -> anyhow::Result { + let ht = image::ImageReader::with_format( + std::io::Cursor::new(png_data), + image::ImageFormat::Png, + ); + let img = ht.decode().unwrap().to_rgb8(); + + let pixels: Vec> = img + .pixels() + .map(|p| { + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ) + }) + .zip(area.points()) + .map(|(color, point)| Pixel(point, color)) + .collect(); + + Ok(Self { + area, + image_data: pixels, + render_fn: Self::default_render, + }) + } + + pub fn new_from_qr_code(area: Rectangle, qr_context: &str) -> anyhow::Result { + let code = qrcode::QrCode::new(qr_context).unwrap(); + let ((width, height), code_pixel) = code + .render::() + .quiet_zone(true) + .module_dimensions(4, 4) + .build(); + + let offset_x = if area.size.width > width { + (area.size.width - width) / 2 + } else { + 0 + }; + let offset_y = if area.size.height > height { + (area.size.height - height) / 2 + } else { + 0 + }; + + let pixels: Vec> = code_pixel + .into_iter() + .map(|p| { + Pixel( + Point::new( + p.0.x + area.top_left.x + offset_x as i32, + p.0.y + area.top_left.y + offset_y as i32, + ), + p.1, + ) + }) + .collect(); + + Ok(Self { + area, + image_data: pixels, + render_fn: Self::default_render, + }) + } + + pub fn default_render(area: &Self, display: &mut DisplayTarget) -> anyhow::Result<()> { + display.draw_iter(area.image_data.iter().cloned())?; + Ok(()) + } +} + +pub struct StartUI { + pub flush_fn: FlushDisplayFn, + pub display_target: Box, +} + +impl StartUI { + pub fn new_with_gif( + mut display_target: Box, + flush_fn: FlushDisplayFn, + gif: &[u8], + ) -> anyhow::Result { + let image = tinygif::Gif::::from_slice(gif) + .map_err(|e| anyhow::anyhow!("Load background GIF Fail: {:?}", e))?; + + for frame in image.frames() { + if !frame.is_transparent { + display_target.clear(ColorFormat::WHITE)?; + } + frame.draw(display_target.as_mut())?; + flush_fn( + display_target.data(), + 0, + 0, + DISPLAY_WIDTH as _, + DISPLAY_HEIGHT as _, + ); + let delay_ms = frame.delay_centis * 10; + std::thread::sleep(std::time::Duration::from_millis(delay_ms as u64)); + } + + Ok(Self { + flush_fn, + display_target, + }) + } + + pub fn new_with_png( + mut display_target: Box, + flush_fn: FlushDisplayFn, + png: &[u8], + delay_ms: u64, + ) -> anyhow::Result { + let ht = + image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); + let img = ht.decode().unwrap().to_rgb8(); + + let p = img + .pixels() + .map(|p| { + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ) + }) + .zip(display_target.bounding_box().points()) + .map(|(color, point)| Pixel(point, color)); + + p.draw(display_target.as_mut())?; + + flush_fn( + display_target.data(), + 0, + 0, + DISPLAY_WIDTH as _, + DISPLAY_HEIGHT as _, + ); + + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + + Ok(Self { + flush_fn, + display_target, + }) + } +} + +pub struct ChatUI { + state_area: (DisplayArea, bool), + asr_area: (DisplayArea, bool), + header_area: (ImageArea, bool), + content_area: (DisplayArea, bool), + + pub flush_fn: FlushDisplayFn, + pub display_target: Box, +} + +impl ChatUI { + pub fn new( + state_area: DisplayArea, + asr_area: DisplayArea, + header_area: ImageArea, + content_area: DisplayArea, + display_target: Box, + flush_fn: FlushDisplayFn, + ) -> Self { + Self { + state_area: (state_area, true), + asr_area: (asr_area, true), + header_area: (header_area, true), + content_area: (content_area, true), + flush_fn, + display_target, + } + } + + pub fn set_state(&mut self, state: String) { + self.state_area.0.text = state; + self.state_area.1 = true; + } + + pub fn set_asr(&mut self, asr: String) { + self.asr_area.0.text = asr; + self.asr_area.1 = true; + } + + pub fn set_text(&mut self, content: String) { + self.content_area.0.text = content; + self.content_area.1 = true; + } + + pub fn flush(&mut self) -> anyhow::Result<()> { + if self.state_area.1 { + (self.state_area.0.render_fn)(&self.state_area.0, self.display_target.as_mut())?; + self.state_area.1 = false; + } + + if self.asr_area.1 { + (self.asr_area.0.render_fn)(&self.asr_area.0, self.display_target.as_mut())?; + self.asr_area.1 = false; + } + + if self.content_area.1 { + (self.content_area.0.render_fn)(&self.content_area.0, self.display_target.as_mut())?; + self.content_area.1 = false; + } + + if self.header_area.1 { + (self.header_area.0.render_fn)(&self.header_area.0, self.display_target.as_mut())?; + self.header_area.1 = false; + } + + (self.flush_fn)( + self.display_target.data(), + 0, + 0, + DISPLAY_WIDTH as _, + DISPLAY_HEIGHT as _, + ); + + Ok(()) + } +} + +pub fn new_chat_ui(start: StartUI) -> anyhow::Result { + let StartUI { + flush_fn, + display_target, + } = start; + let bounding_box = display_target.bounding_box(); + + let state_area_box = Rectangle::new( + bounding_box.top_left + Point::new(96, 0), + Size::new(bounding_box.size.width - 96, 32), + ); + + let state_area = DisplayArea::new_text_area( + state_area_box, + get_background_pixels( + display_target.as_ref(), + state_area_box, + PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_BLUE) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_BLUE) + .build(), + 0.5, + ), + String::new(), + |area, display| { + area.background.iter().cloned().draw(display)?; + Text::with_alignment( + &area.text, + area.area.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_LIGHT_CYAN, + ), + Alignment::Center, + ) + .draw(display)?; + Ok(()) + }, + ); + + let asr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(96, 32), + Size::new(bounding_box.size.width - 96, 64), + ); + + let asr_area = DisplayArea::new_text_area( + asr_area_box, + get_background_pixels( + display_target.as_ref(), + asr_area_box, + PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_CYAN) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_CYAN) + .build(), + 0.15, + ), + String::new(), + |area, display| { + area.background.iter().cloned().draw(display)?; + Text::with_alignment( + &area.text, + area.area.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_WHEAT, + ), + Alignment::Center, + ) + .draw(display)?; + Ok(()) + }, + ); + + let content_height = bounding_box.size.height - 32 - 64; + let content_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, 32 + 64), + Size::new(bounding_box.size.width, content_height), + ); + + let content_area = DisplayArea::new_text_area( + content_area_box, + get_background_pixels( + display_target.as_ref(), + content_area_box, + PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_BLACK) + .stroke_width(5) + .fill_color(ColorFormat::CSS_BLACK) + .build(), + 0.25, + ), + String::new(), + |area, display| { + area.background.iter().cloned().draw(display)?; + let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() + .height_mode(embedded_text::style::HeightMode::FitToText) + .alignment(embedded_text::alignment::HorizontalAlignment::Center) + .line_height(embedded_graphics::text::LineHeight::Percent(120)) + .paragraph_spacing(16) + .build(); + let text_box = TextBox::with_textbox_style( + &area.text, + area.area, + MyTextStyle( + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy16_t_gb2312, + ColorFormat::CSS_WHEAT, + ), + 3, + ), + textbox_style, + ); + text_box.draw(display)?; + Ok(()) + }, + ); + + let header_area_box = Rectangle::new(bounding_box.top_left, Size::new(96, 96)); + let header_area = ImageArea::new_from_png(header_area_box, AVATAR_PNG)?; + + Ok(ChatUI::new( + state_area, + asr_area, + header_area, + content_area, + display_target, + flush_fn, + )) +} + +pub struct ConfiguresUI { + qr_area: ImageArea, + info_area: DisplayArea, + + pub flush_fn: FlushDisplayFn, + pub display_target: Box, +} + +impl ConfiguresUI { + pub fn new( + qr_area: ImageArea, + info_area: DisplayArea, + display_target: Box, + flush_fn: FlushDisplayFn, + ) -> Self { + Self { + qr_area, + info_area, + flush_fn, + display_target, + } + } + + pub fn set_info(&mut self, info: String) { + self.info_area.text = info; + } + + pub fn flush(&mut self) -> anyhow::Result<()> { + (self.info_area.render_fn)(&self.info_area, self.display_target.as_mut())?; + (self.qr_area.render_fn)(&self.qr_area, self.display_target.as_mut())?; + + (self.flush_fn)( + self.display_target.data(), + 0, + 0, + DISPLAY_WIDTH as _, + DISPLAY_HEIGHT as _, + ); + + Ok(()) + } +} + +pub fn new_config_ui(start: StartUI, qr_content: &str) -> anyhow::Result { + let StartUI { + flush_fn, + display_target, + } = start; + let bounding_box = display_target.bounding_box(); + + let height = bounding_box.size.height; + + let qr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, height as i32 / 3), + Size::new(bounding_box.size.width, 2 * height / 3), + ); + let qr_area = ImageArea::new_from_qr_code(qr_area_box, qr_content)?; + + let info_area = DisplayArea::new_text_area( + bounding_box, + get_background_pixels( + display_target.as_ref(), + bounding_box, + PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_BLUE) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_BLUE) + .build(), + 0.25, + ), + String::new(), + |area, display| { + area.background.iter().cloned().draw(display)?; + Text::with_alignment( + &area.text, + area.area.top_left + Point::new(area.area.size.width as i32 / 2, 32), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_WHEAT, + ), + Alignment::Center, + ) + .draw(display)?; + Ok(()) + }, + ); + + Ok(ConfiguresUI::new( + qr_area, + info_area, + display_target, + flush_fn, + )) +} From 89b6db766114cf7f8bd6a6a211d126dec5c1560e Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Fri, 2 Jan 2026 01:10:53 +0800 Subject: [PATCH 02/22] remove old ui --- src/ui.rs | 269 ------------------------------------------------------ 1 file changed, 269 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index e44dc66..2b056b9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -90,8 +90,6 @@ pub fn backgroud(gif: &[u8], f: FlushDisplayFn) -> Result<(), std::convert::Infa Ok(()) } -const ALPHA: f32 = 0.5; - // TextRenderer + CharacterStyle #[derive(Debug, Clone)] struct MyTextStyle(U8g2TextStyle, i32); @@ -177,21 +175,6 @@ type DisplayTarget = Framebuffer< { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, >; -pub struct UI { - pub state: String, - state_area: Rectangle, - state_background: Vec>, - pub text: String, - text_area: Rectangle, - text_background: Vec>, - - display: Box, - - flush_fn: FlushDisplayFn, -} - -const COLOR_WIDTH: u32 = 2; - fn alpha_mix(source: ColorFormat, target: ColorFormat, alpha: f32) -> ColorFormat { ColorFormat::new( ((1. - alpha) * source.r() as f32 + alpha * target.r() as f32) as u8, @@ -200,35 +183,6 @@ fn alpha_mix(source: ColorFormat, target: ColorFormat, alpha: f32) -> ColorForma ) } -fn flush_area( - data: &[u8], - size: Size, - area: Rectangle, - flash_fn: FlushDisplayFn, -) -> i32 { - let start_y = area.top_left.y as u32; - let end_y = start_y + area.size.height; - - let start_index = start_y * size.width * COLOR_WIDTH; - let data_len = area.size.height * size.width * COLOR_WIDTH; - if let Some(area_data) = data.get(start_index as usize..(start_index + data_len) as usize) { - flash_fn( - area_data, - 0, - start_y as i32, - size.width as i32, - end_y as i32, - ) - } else { - log::warn!("flush_area error: data out of bounds"); - log::warn!( - "start_index: {start_index}, area_len: {data_len}, data_len: {}", - data.len() - ); - -1 - } -} - #[derive(Debug, Clone, Copy)] pub struct QrPixel(ColorFormat); @@ -283,229 +237,6 @@ impl qrcode::render::Canvas for QrCanvas { } } -impl UI { - pub fn new(backgroud_gif: Option<&[u8]>, flush_fn: FlushDisplayFn) -> anyhow::Result { - let mut display = Box::new(Framebuffer::< - ColorFormat, - _, - LittleEndian, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, - >::new()); - - display.clear(ColorFormat::WHITE).unwrap(); - - let state_area = Rectangle::new( - display.bounding_box().top_left + Point::new(0, 0), - Size::new(DISPLAY_WIDTH as u32, 32), - ); - let text_area = Rectangle::new( - display.bounding_box().top_left + Point::new(0, 32), - Size::new(DISPLAY_WIDTH as u32, DISPLAY_HEIGHT as u32 - 32), - ); - - if let Some(gif) = backgroud_gif { - let image = tinygif::Gif::::from_slice(gif) - .map_err(|e| anyhow::anyhow!("Failed to parse GIF: {:?}", e))?; - for frame in image.frames() { - frame.draw(display.as_mut()).unwrap(); - } - } - - let img = display.as_image(); - - let state_pixels: Vec> = state_area - .into_styled( - PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_DARK_BLUE) - .stroke_width(1) - .fill_color(ColorFormat::CSS_DARK_BLUE) - .build(), - ) - .pixels() - .map(|p| { - if let Some(color) = img.pixel(p.0) { - Pixel(p.0, alpha_mix(color, p.1, ALPHA)) - } else { - p - } - }) - .collect(); - - let box_pixels: Vec> = text_area - .into_styled( - PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_BLACK) - .stroke_width(5) - .fill_color(ColorFormat::CSS_BLACK) - .build(), - ) - .pixels() - .map(|p| { - if let Some(color) = img.pixel(p.0) { - Pixel(p.0, alpha_mix(color, p.1, ALPHA)) - } else { - p - } - }) - .collect(); - - Ok(Self { - state: String::new(), - state_background: state_pixels, - text: String::new(), - text_background: box_pixels, - display, - state_area, - text_area, - flush_fn, - }) - } - - pub fn display_flush(&mut self) -> anyhow::Result<()> { - self.state_background - .iter() - .cloned() - .draw(self.display.as_mut())?; - self.text_background - .iter() - .cloned() - .draw(self.display.as_mut())?; - - Text::with_alignment( - &self.state, - self.state_area.center(), - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, - ColorFormat::CSS_LIGHT_CYAN, - ), - Alignment::Center, - ) - .draw(self.display.as_mut())?; - - let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() - .height_mode(embedded_text::style::HeightMode::FitToText) - .alignment(embedded_text::alignment::HorizontalAlignment::Center) - .line_height(embedded_graphics::text::LineHeight::Percent(120)) - .paragraph_spacing(16) - .build(); - let text_box = TextBox::with_textbox_style( - &self.text, - self.text_area, - MyTextStyle( - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy16_t_gb2312, - ColorFormat::CSS_WHEAT, - ), - 3, - ), - textbox_style, - ); - text_box.draw(self.display.as_mut())?; - - for i in 0..5 { - let e = flush_area::( - self.display.data(), - self.display.size(), - Rectangle::new( - self.state_area.top_left, - Size::new( - self.text_area.size.width, - self.text_area.size.height + self.state_area.size.height, - ), - ), - self.flush_fn, - ); - if e == 0 { - break; - } - log::warn!("flush_display error: {} retry {i}", e); - } - Ok(()) - } - - pub fn display_qrcode(&mut self, qr_context: &str) -> anyhow::Result<()> { - let code = qrcode::QrCode::new(qr_context).unwrap(); - let ((width, height), code_pixel) = code - .render::() - .quiet_zone(true) - .module_dimensions(4, 4) - .build(); - - self.state_background - .iter() - .cloned() - .draw(self.display.as_mut())?; - self.text_background - .iter() - .cloned() - .draw(self.display.as_mut())?; - - self.display - .cropped(&Rectangle::new( - self.text_area.top_left - + Point::new( - ((self.text_area.size.width - width) / 2) as i32, - (self.text_area.size.height - height) as i32, - ), - Size::new(width, height), - )) - .draw_iter(code_pixel)?; - - Text::with_alignment( - &self.state, - self.state_area.center(), - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, - ColorFormat::CSS_LIGHT_CYAN, - ), - Alignment::Center, - ) - .draw(self.display.as_mut())?; - - let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() - .height_mode(embedded_text::style::HeightMode::FitToText) - .alignment(embedded_text::alignment::HorizontalAlignment::Center) - .line_height(embedded_graphics::text::LineHeight::Percent(120)) - .paragraph_spacing(12) - .build(); - let text_box = TextBox::with_textbox_style( - &self.text, - self.text_area, - MyTextStyle( - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, - ColorFormat::CSS_WHEAT, - ), - 3, - ), - textbox_style, - ); - text_box.draw(self.display.as_mut())?; - - for i in 0..5 { - let e = flush_area::( - self.display.data(), - self.display.size(), - Rectangle::new( - self.state_area.top_left, - Size::new( - self.text_area.size.width, - self.text_area.size.height + self.state_area.size.height, - ), - ), - self.flush_fn, - ); - if e == 0 { - break; - } - log::warn!("flush_display error: {} retry {i}", e); - } - Ok(()) - } -} - pub struct DisplayArea { area: Rectangle, background: Vec>, From ac0121d0b9a047fc3abe2a3e8f602b63737b73af Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 5 Jan 2026 18:38:00 +0800 Subject: [PATCH 03/22] better gif --- Cargo.toml | 7 ++- src/main.rs | 4 +- src/ui.rs | 139 ++++++++++++++++++++++++++++++---------------------- 3 files changed, 89 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ba16c2f..99ee8ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,11 @@ esp32-nimble = "0.11.1" embedded-graphics = "0.8.1" embedded-text = "0.7.2" u8g2-fonts = { version = "0.6.0", features = ["embedded_graphics_textstyle"] } -tinygif = "0.0.4" -image = { version = "0.25.6", default-features = false, features = ["png"] } +image = { version = "0.25.6", default-features = false, features = [ + "png", + "gif", + "webp", +] } futures-util = { version = "0.3.31", features = ["sink"] } diff --git a/src/main.rs b/src/main.rs index c1c9031..d3be8a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,8 @@ fn main() -> anyhow::Result<()> { crate::start_hal!(peripherals, evt_tx); + ui::background(&setting.background_gif.0, boards::flush_display).unwrap(); + let start_ui = if setting.background_gif.0.is_empty() { log::info!("No background GIF found, using default start UI"); ui::StartUI { @@ -219,7 +221,7 @@ fn main() -> anyhow::Result<()> { let mut new_gif = Vec::new(); std::mem::swap(&mut setting.0.background_gif.0, &mut new_gif); - let _ = ui::backgroud(&new_gif, boards::flush_display); + let _ = ui::background(&new_gif, boards::flush_display); log::info!("Background GIF set from NVS"); config_ui.set_info("Background GIF set OK".to_string()); diff --git a/src/ui.rs b/src/ui.rs index 2b056b9..16e76a6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,3 @@ -use std::u8; - use embedded_graphics::{ framebuffer::{buffer_size, Framebuffer}, image::GetPixel, @@ -30,8 +28,8 @@ pub const AVATAR_PNG: &[u8] = include_bytes!("../assets/96x96.png"); pub type FlushDisplayFn = fn(color_data: &[u8], x_start: i32, y_start: i32, x_end: i32, y_end: i32) -> i32; -pub fn backgroud(gif: &[u8], f: FlushDisplayFn) -> Result<(), std::convert::Infallible> { - let image = tinygif::Gif::::from_slice(gif).unwrap(); +pub fn background(gif: &[u8], f: FlushDisplayFn) -> anyhow::Result<()> { + use image::AnimationDecoder; // Create a new framebuffer let mut display = Box::new(Framebuffer::< @@ -42,40 +40,38 @@ pub fn backgroud(gif: &[u8], f: FlushDisplayFn) -> Result<(), std::convert::Infa DISPLAY_HEIGHT, { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, >::new()); + display.clear(ColorFormat::WHITE)?; - let ht = image::ImageReader::with_format(std::io::Cursor::new(LM_PNG), image::ImageFormat::Png); - let img = ht.decode().unwrap().to_rgb8(); + let img_gif = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(gif))?; - let p = img - .pixels() - .map(|p| { - ColorFormat::new( - p[0] / (u8::MAX / ColorFormat::MAX_R), - p[1] / (u8::MAX / ColorFormat::MAX_G), - p[2] / (u8::MAX / ColorFormat::MAX_B), - ) - }) - .zip(display.bounding_box().points()) - .map(|(color, point)| Pixel(point, color)); - - p.draw(display.as_mut()).unwrap(); - f( - display.data(), - 0, - 0, - DISPLAY_WIDTH as _, - DISPLAY_HEIGHT as _, - ); + for ff in img_gif.into_frames() { + let frame = ff?; - std::thread::sleep(std::time::Duration::from_millis(30 * 1000)); + let delay = frame.delay(); - display.clear(ColorFormat::WHITE)?; + let img = frame.into_buffer(); + + for (x, y, p) in img.enumerate_pixels() { + if x >= display.size().width || y >= display.size().height || p[3] == 0 { + continue; + } - for frame in image.frames() { - if !frame.is_transparent { - display.clear(ColorFormat::WHITE)?; + display.set_pixel( + Point { + x: x as i32, + y: y as i32, + }, + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ), + ); } - frame.draw(display.as_mut())?; + + let now = std::time::Instant::now(); + let delay = std::time::Duration::from(delay); + f( display.data(), 0, @@ -83,8 +79,8 @@ pub fn backgroud(gif: &[u8], f: FlushDisplayFn) -> Result<(), std::convert::Infa DISPLAY_WIDTH as _, DISPLAY_HEIGHT as _, ); - let delay_ms = frame.delay_centis * 10; - std::thread::sleep(std::time::Duration::from_millis(delay_ms as u64)); + + std::thread::sleep(std::time::Instant::now() - (now + delay)); } Ok(()) @@ -318,18 +314,21 @@ impl ImageArea { ); let img = ht.decode().unwrap().to_rgb8(); - let pixels: Vec> = img - .pixels() - .map(|p| { + let mut pixels = Vec::with_capacity((area.size.width * area.size.height) as usize); + + for (x, y, p) in img.enumerate_pixels() { + if x >= area.size.width || y >= area.size.height { + continue; + } + pixels.push(Pixel( + Point::new(area.top_left.x + x as i32, area.top_left.y + y as i32), ColorFormat::new( p[0] / (u8::MAX / ColorFormat::MAX_R), p[1] / (u8::MAX / ColorFormat::MAX_G), p[2] / (u8::MAX / ColorFormat::MAX_B), - ) - }) - .zip(area.points()) - .map(|(color, point)| Pixel(point, color)) - .collect(); + ), + )); + } Ok(Self { area, @@ -394,14 +393,37 @@ impl StartUI { flush_fn: FlushDisplayFn, gif: &[u8], ) -> anyhow::Result { - let image = tinygif::Gif::::from_slice(gif) - .map_err(|e| anyhow::anyhow!("Load background GIF Fail: {:?}", e))?; + use image::AnimationDecoder; + let img_gif = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(gif)).unwrap(); + + let frames = img_gif.into_frames(); + for ff in frames { + let frame = ff.unwrap(); + + let delay = frame.delay(); + + let img = frame.into_buffer(); + let pixels = img.enumerate_pixels().map(|(x, y, p)| { + let (x, y) = if p[3] == 0 { + (-1, -1) + } else { + (x as i32, y as i32) + }; + + Pixel( + Point { x, y }, + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ), + ) + }); + + display_target.draw_iter(pixels)?; + + let now = std::time::Instant::now(); - for frame in image.frames() { - if !frame.is_transparent { - display_target.clear(ColorFormat::WHITE)?; - } - frame.draw(display_target.as_mut())?; flush_fn( display_target.data(), 0, @@ -409,8 +431,10 @@ impl StartUI { DISPLAY_WIDTH as _, DISPLAY_HEIGHT as _, ); - let delay_ms = frame.delay_centis * 10; - std::thread::sleep(std::time::Duration::from_millis(delay_ms as u64)); + + let delay = std::time::Duration::from(delay); + + std::thread::sleep(std::time::Instant::now() - (now + delay)); } Ok(Self { @@ -429,17 +453,16 @@ impl StartUI { image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); let img = ht.decode().unwrap().to_rgb8(); - let p = img - .pixels() - .map(|p| { + let p = img.enumerate_pixels().map(|(x, y, p)| { + Pixel( + Point::new(x as i32, y as i32), ColorFormat::new( p[0] / (u8::MAX / ColorFormat::MAX_R), p[1] / (u8::MAX / ColorFormat::MAX_G), p[2] / (u8::MAX / ColorFormat::MAX_B), - ) - }) - .zip(display_target.bounding_box().points()) - .map(|(color, point)| Pixel(point, color)); + ), + ) + }); p.draw(display_target.as_mut())?; From e34d8878aa35eb370ac2a97573b00f0c7d35ffb3 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Wed, 7 Jan 2026 21:26:20 +0800 Subject: [PATCH 04/22] feat: support vowel --- assets/xx.gif | Bin 0 -> 40452 bytes src/app.rs | 56 ++++++++++++++++------------------------ src/audio.rs | 49 +++++++++++++++++++++++++++-------- src/protocol.rs | 3 ++- src/ui.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++---- src/ws.rs | 37 +++++++++++++++++++++++---- 6 files changed, 155 insertions(+), 56 deletions(-) create mode 100644 assets/xx.gif diff --git a/assets/xx.gif b/assets/xx.gif new file mode 100644 index 0000000000000000000000000000000000000000..f7599709af9dfa00420b2a09eec71228e45e4971 GIT binary patch literal 40452 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%0A>ZBOOOsn3mX#E4dG2lKMNDWTq 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 literal 0 HcmV?d00001 diff --git a/src/app.rs b/src/app.rs index e72abc1..7b5d6d0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,7 @@ pub enum Event { ServerEvent(ServerEvent), MicAudioChunk(Vec), MicAudioEnd, + Vowel(u8), MicInterruptWaitTimeout, #[cfg_attr(not(feature = "extra_server"), allow(unused))] ServerUrl(String), @@ -22,7 +23,6 @@ pub enum Event { #[allow(unused)] impl Event { pub const IDLE: &'static str = "idle"; - pub const GAIA: &'static str = "gaia"; pub const NO: &'static str = "no"; pub const YES: &'static str = "yes"; pub const NOISE: &'static str = "noise"; @@ -85,6 +85,9 @@ async fn select_evt( Event::MicInterruptWaitTimeout => { log::info!("[Select] Received MicInterruptWaitTimeout"); } + Event::Vowel(v) => { + log::debug!("[Select] Received Vowel: {}", v); + } Event::ServerUrl(url) => { log::info!("[Select] Received ServerUrl: {}", url); } @@ -199,7 +202,7 @@ pub async fn main_work<'d>( while let Some(evt) = select_evt(&mut evt_rx, &mut server, ¬ify, wait_notify, timeout).await { match evt { - Event::Event(Event::GAIA | Event::K0) => { + Event::Event(Event::K0) => { log::info!("Received event: k0"); if state == State::Listening { @@ -208,6 +211,8 @@ pub async fn main_work<'d>( gui.flush()?; server.close().await?; } else { + server.reconnect_with_retry(3).await?; + let hello_notify = Arc::new(tokio::sync::Notify::new()); player_tx .send(AudioEvent::Hello(hello_notify.clone())) @@ -215,8 +220,6 @@ pub async fn main_work<'d>( log::info!("Waiting for hello response"); let _ = hello_notify.notified().await; - server.reconnect_with_retry(3).await?; - start_submit = false; submit_audio = 0.0; audio_buffer = Vec::with_capacity(8192); @@ -290,6 +293,10 @@ pub async fn main_work<'d>( Event::Event(evt) => { log::info!("Received event: {:?}", evt); } + Event::Vowel(v) => { + gui.set_header(v as usize); + gui.flush()?; + } Event::MicAudioChunk(data) if state == State::Listening => { submit_audio += data.len() as f32 / 16000.0; audio_buffer.extend_from_slice(&data); @@ -433,35 +440,7 @@ pub async fn main_work<'d>( .send(AudioEvent::StartSpeech) .map_err(|e| anyhow::anyhow!("Error sending start: {e:?}"))?; } - Event::ServerEvent(ServerEvent::AudioChunk { data }) => { - log::debug!("Received audio chunk"); - if state != State::Speaking { - log::debug!("Received audio chunk while not speaking"); - continue; - } - - if need_compute { - if start_audio { - metrics.reset(); - start_audio = false; - } - metrics.add_data(data.len()); - } - - if speed < SPEED_LIMIT { - if let Err(e) = player_tx.send(AudioEvent::SpeechChunk(data)) { - log::error!("Error sending audio chunk: {:?}", e); - gui.set_state("Error on audio chunk".to_string()); - gui.flush().unwrap(); - } - } else { - let data_ = unsafe { - std::slice::from_raw_parts(data.as_ptr() as *const i16, data.len() / 2) - }; - recv_audio_buffer.extend_from_slice(data_); - } - } - Event::ServerEvent(ServerEvent::AudioChunki16 { data }) => { + Event::ServerEvent(ServerEvent::AudioChunki16 { data, vowel }) => { log::debug!("Received audio chunk"); if state != State::Speaking { log::debug!("Received audio chunk while not speaking"); @@ -477,7 +456,8 @@ pub async fn main_work<'d>( } if speed < SPEED_LIMIT { - if let Err(e) = player_tx.send(AudioEvent::SpeechChunki16(data)) { + if let Err(e) = player_tx.send(AudioEvent::SpeechChunki16WithVowel(data, vowel)) + { log::error!("Error sending audio chunk: {:?}", e); gui.set_state("Error on audio chunk".to_string()); gui.flush().unwrap(); @@ -554,6 +534,14 @@ pub async fn main_work<'d>( } Event::ServerEvent(ServerEvent::StartVideo | ServerEvent::EndVideo) => {} + Event::ServerEvent(ServerEvent::AudioChunk { .. }) => { + log::warn!("Received deprecated AudioChunk, please use AudioChunki16 instead"); + } + Event::ServerEvent(ServerEvent::AudioChunkWithVowel { .. }) => { + log::warn!( + "Received deprecated AudioChunkWithVowel, please use AudioChunki16 instead" + ); + } Event::ServerUrl(url) => { log::info!("Received ServerUrl: {}", url); if url != server.url { diff --git a/src/audio.rs b/src/audio.rs index 3e6a3c4..e8555eb 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -224,13 +224,14 @@ pub enum AudioEvent { StopSpeech, StartSpeech, ClearSpeech, - SpeechChunk(Vec), SpeechChunki16(Vec), + SpeechChunki16WithVowel(Vec, u8), EndSpeech(Arc), VolSet(u8), } pub enum SendBufferItem { + Vowel(u8), Audio(Vec), EndSpeech(Arc), } @@ -340,14 +341,19 @@ impl SendBuffer { } } + pub fn push_vowel(&mut self, vowel: u8) { + self.cache.push_back(SendBufferItem::Vowel(vowel)); + } + pub fn push_back_end_speech(&mut self, notify: Arc) { self.cache.push_back(SendBufferItem::EndSpeech(notify)); } - pub fn get_chunk(&mut self) -> Option> { + pub fn get_chunk(&mut self) -> Option { loop { match self.cache.pop_front() { - Some(SendBufferItem::Audio(v)) => return Some(v), + Some(SendBufferItem::Vowel(v)) => return Some(SendBufferItem::Vowel(v)), + Some(SendBufferItem::Audio(v)) => return Some(SendBufferItem::Audio(v)), Some(SendBufferItem::EndSpeech(notify)) => { let _ = notify.notify_one(); continue; @@ -412,6 +418,7 @@ const CHUNK_SIZE: usize = 256; fn audio_task_run( rx: &mut tokio::sync::mpsc::UnboundedReceiver, + tx: EventTx, fn_read: &mut dyn FnMut(&mut [i16]) -> Result, fn_write: &mut dyn FnMut(&[i16]) -> Result, afe_handle: Arc, @@ -479,8 +486,9 @@ fn audio_task_run( AudioEvent::ClearSpeech => { send_buffer.clear(); } - AudioEvent::SpeechChunk(items) => { - send_buffer.push_u8(&items); + AudioEvent::SpeechChunki16WithVowel(items, vowel) => { + send_buffer.push_vowel(vowel); + send_buffer.push_i16(&items); } AudioEvent::SpeechChunki16(items) => { send_buffer.push_i16(&items); @@ -503,7 +511,20 @@ fn audio_task_run( } } let play_data_ = if allow_speech { - send_buffer.get_chunk() + loop { + break match send_buffer.get_chunk() { + Some(SendBufferItem::Audio(v)) => Some(v), + Some(SendBufferItem::Vowel(v)) => { + tx.blocking_send(crate::app::Event::Vowel(v)) + .map_err(|_| anyhow::anyhow!("Failed to send vowel event"))?; + continue; + } + Some(SendBufferItem::EndSpeech(_)) => { + unreachable!("EndSpeech should be handled in get_chunk") + } + None => None, + }; + } } else { None }; @@ -615,6 +636,7 @@ impl BoxAudioWorker { let afe_handle = Arc::new(AFE::new()); let afe_handle_ = afe_handle.clone(); crate::log_heap(); + let tx_ = tx.clone(); let _afe_r = std::thread::Builder::new().stack_size(8 * 1024).spawn(|| { let r = afe_worker(afe_handle_, tx); @@ -623,7 +645,7 @@ impl BoxAudioWorker { } })?; - audio_task_run(&mut rx, &mut fn_read, &mut fn_write, afe_handle) + audio_task_run(&mut rx, tx_, &mut fn_read, &mut fn_write, afe_handle) } } @@ -706,10 +728,15 @@ impl BoardsAudioWorker { let afe_handle = Arc::new(AFE::new()); let afe_handle_ = afe_handle.clone(); - let _afe_r = std::thread::Builder::new() - .stack_size(8 * 1024) - .spawn(|| afe_worker(afe_handle_, tx))?; + let tx_ = tx.clone(); + + let _afe_r = std::thread::Builder::new().stack_size(8 * 1024).spawn(|| { + let r = afe_worker(afe_handle_, tx); + if let Err(e) = r { + log::error!("AFE worker error: {:?}", e); + } + })?; - audio_task_run(&mut rx, &mut fn_read, &mut fn_write, afe_handle) + audio_task_run(&mut rx, tx_, &mut fn_read, &mut fn_write, afe_handle) } } diff --git a/src/protocol.rs b/src/protocol.rs index d249e8a..4c24d3b 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -11,7 +11,8 @@ pub enum ServerEvent { Action { action: String }, StartAudio { text: String }, AudioChunk { data: Vec }, - AudioChunki16 { data: Vec }, + AudioChunkWithVowel { data: Vec, vowel: u8 }, + AudioChunki16 { data: Vec, vowel: u8 }, EndAudio, StartVideo, EndVideo, diff --git a/src/ui.rs b/src/ui.rs index 16e76a6..47f852b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -23,7 +23,7 @@ pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/ht.gif"); use crate::boards::{DISPLAY_HEIGHT, DISPLAY_WIDTH}; pub const LM_PNG: &[u8] = include_bytes!("../assets/lm_320x240.png"); -pub const AVATAR_PNG: &[u8] = include_bytes!("../assets/96x96.png"); +pub const AVATAR_GIF: &[u8] = include_bytes!("../assets/xx.gif"); pub type FlushDisplayFn = fn(color_data: &[u8], x_start: i32, y_start: i32, x_end: i32, y_end: i32) -> i32; @@ -382,6 +382,57 @@ impl ImageArea { } } +pub struct DynamicImage { + pub display_index: usize, + pub image_data: Vec>>, +} + +impl DynamicImage { + pub fn new_from_gif(area: Rectangle, gif_data: &[u8]) -> anyhow::Result { + use image::AnimationDecoder; + let img_gif = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(gif_data))?; + + let frames = img_gif.into_frames(); + let mut image_data: Vec>> = Vec::new(); + for ff in frames.take(N) { + let frame = ff?; + + let img = frame.into_buffer(); + let mut pixels = Vec::with_capacity((area.size.width * area.size.height) as usize); + + for (x, y, p) in img.enumerate_pixels() { + if x >= area.size.width || y >= area.size.height || p[3] == 0 { + continue; + } + pixels.push(Pixel( + Point::new(area.top_left.x + x as i32, area.top_left.y + y as i32), + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ), + )); + } + + image_data.push(pixels); + } + + Ok(Self { + display_index: 0, + image_data, + }) + } + + pub fn set_index(&mut self, index: usize) { + self.display_index = index % N; + } + + pub fn render(&self, display: &mut DisplayTarget) -> anyhow::Result<()> { + display.draw_iter(self.image_data[self.display_index].iter().cloned())?; + Ok(()) + } +} + pub struct StartUI { pub flush_fn: FlushDisplayFn, pub display_target: Box, @@ -486,7 +537,7 @@ impl StartUI { pub struct ChatUI { state_area: (DisplayArea, bool), asr_area: (DisplayArea, bool), - header_area: (ImageArea, bool), + header_area: (DynamicImage<4>, bool), content_area: (DisplayArea, bool), pub flush_fn: FlushDisplayFn, @@ -497,7 +548,7 @@ impl ChatUI { pub fn new( state_area: DisplayArea, asr_area: DisplayArea, - header_area: ImageArea, + header_area: DynamicImage<4>, content_area: DisplayArea, display_target: Box, flush_fn: FlushDisplayFn, @@ -527,6 +578,11 @@ impl ChatUI { self.content_area.1 = true; } + pub fn set_header(&mut self, index: usize) { + self.header_area.0.set_index(index); + self.header_area.1 = true; + } + pub fn flush(&mut self) -> anyhow::Result<()> { if self.state_area.1 { (self.state_area.0.render_fn)(&self.state_area.0, self.display_target.as_mut())?; @@ -544,7 +600,7 @@ impl ChatUI { } if self.header_area.1 { - (self.header_area.0.render_fn)(&self.header_area.0, self.display_target.as_mut())?; + self.header_area.0.render(self.display_target.as_mut())?; self.header_area.1 = false; } @@ -680,7 +736,7 @@ pub fn new_chat_ui(start: StartUI) -> anyhow::Result { ); let header_area_box = Rectangle::new(bounding_box.top_left, Size::new(96, 96)); - let header_area = ImageArea::new_from_png(header_area_box, AVATAR_PNG)?; + let header_area = DynamicImage::new_from_gif(header_area_box, AVATAR_GIF)?; Ok(ChatUI::new( state_area, diff --git a/src/ws.rs b/src/ws.rs index 922d8bf..06c9f00 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -61,7 +61,28 @@ async fn ws_manager( .iter() .cloned() .collect::>(); - let server_event = ServerEvent::AudioChunki16 { data }; + let server_event = + ServerEvent::AudioChunki16 { data, vowel: 0 }; + tx.send(server_event).await.map_err(|_| { + anyhow::anyhow!( + "Failed to send opus audio chunk to channel", + ) + })?; + } + Err(e) => { + log::warn!("Failed to decode opus audio chunk: {}", e); + continue; + } + } + } + Ok(ServerEvent::AudioChunkWithVowel { data, vowel }) => { + match opus_decoder.decode(&data, &mut opus_buffer, false) { + Ok(decoded_samples) => { + let data = opus_buffer[..decoded_samples] + .iter() + .cloned() + .collect::>(); + let server_event = ServerEvent::AudioChunki16 { data, vowel }; tx.send(server_event).await.map_err(|_| { anyhow::anyhow!( "Failed to send opus audio chunk to channel", @@ -171,9 +192,9 @@ pub struct Server { impl Server { pub async fn new(id: String, url: String) -> anyhow::Result { let u = if url.ends_with("/") { - format!("{}{}?opus=true", url, id) + format!("{}{}?opus=true&vowel=true", url, id) } else { - format!("{}/{}?opus=true", url, id) + format!("{}/{}?opus=true&vowel=true", url, id) }; let (ws, _resp) = tokio_websockets::ClientBuilder::new() @@ -205,9 +226,15 @@ impl Server { pub async fn reconnect(&mut self) -> anyhow::Result<()> { let u = if self.url.ends_with("/") { - format!("{}{}?reconnect=true&opus=true", self.url, self.id) + format!( + "{}{}?reconnect=true&opus=true&vowel=true", + self.url, self.id + ) } else { - format!("{}/{}?reconnect=true&opus=true", self.url, self.id) + format!( + "{}/{}?reconnect=true&opus=true&vowel=true", + self.url, self.id + ) }; let (ws, _resp) = tokio_websockets::ClientBuilder::new() From 3c87c476f3947e9756ab3dc3091c0542980c7491 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 12 Jan 2026 01:27:28 +0800 Subject: [PATCH 05/22] optimize lcd flush --- Cargo.toml | 7 +- components/hal_driver/lcd.c | 17 +- components/hal_driver/lcd.h | 1 + src/app.rs | 71 ++++-- src/boards/atom_box.rs | 489 ++++++++++++++++++++++++++++++++++++ src/boards/mod.rs | 347 +++++++++++++++++++++++++ src/main.rs | 83 +++--- src/ui.rs | 135 ++++++++-- 8 files changed, 1069 insertions(+), 81 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 99ee8ad..89a6767 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,11 +24,11 @@ default = ["boards"] experimental = ["esp-idf-svc/experimental"] -boards = ["voice_interrupt"] +boards = ["voice_interrupt", "base_ui"] _no_default = [] box = ["_no_default", "voice_interrupt"] -cube = ["_no_default", "voice_interrupt"] -cube2 = ["_no_default", "voice_interrupt"] +cube = ["_no_default", "voice_interrupt", "base_ui"] +cube2 = ["_no_default", "voice_interrupt", "base_ui"] nfc_cube2 = ["cube2", "mfrc522", "exio"] mfrc522 = ["i2c", "dep:ndef", "extra_server"] @@ -38,6 +38,7 @@ extra_server = [] i2c = [] voice_interrupt = [] +base_ui = [] [dependencies] log = "0.4" diff --git a/components/hal_driver/lcd.c b/components/hal_driver/lcd.c index 0aa2b7d..0ccab51 100644 --- a/components/hal_driver/lcd.c +++ b/components/hal_driver/lcd.c @@ -24,6 +24,8 @@ static const char *TAG = "LCD"; esp_lcd_panel_handle_t panel_handle = NULL; /* LCD句柄 */ +uint16_t *lcd_dma_buffer = NULL; + uint32_t g_back_color = 0xFFFF; lcd_obj_t lcd_dev; @@ -129,23 +131,23 @@ void lcd_color_fill(uint16_t sx, uint16_t sy, uint16_t ex, uint16_t ey, uint16_t uint16_t height = ey - sy; uint32_t buf_index = 0; - uint16_t *buffer = heap_caps_malloc(width * sizeof(uint16_t), MALLOC_CAP_INTERNAL); + // uint16_t *buffer = heap_caps_malloc(width * sizeof(uint16_t), MALLOC_CAP_INTERNAL); for (uint16_t y_index = 0; y_index < height; y_index++) { for (uint16_t x_index = 0; x_index < width; x_index++) { - buffer[x_index] = color[buf_index]; + lcd_dma_buffer[x_index] = color[buf_index]; buf_index++; } for (uint16_t i = 0; i < width; i += 80) { - esp_lcd_panel_draw_bitmap(panel_handle, sx + i, sy + y_index, sx + i + 80, sy + y_index + 1, &buffer[i]); + esp_lcd_panel_draw_bitmap(panel_handle, sx + i, sy + y_index, sx + i + 80, sy + y_index + 1, &lcd_dma_buffer[i]); } } /* 释放内存 */ - heap_caps_free(buffer); + // heap_caps_free(buffer); } /** @@ -284,7 +286,8 @@ void lcd_init(lcd_cfg_t lcd_config) }, .bus_width = 8, .max_transfer_bytes = lcd_dev.pwidth * lcd_dev.pheight * sizeof(uint16_t), - .psram_trans_align = 64, + // .psram_trans_align = 64, + .dma_burst_size = 64, .sram_trans_align = 4, }; ESP_ERROR_CHECK(esp_lcd_new_i80_bus(&bus_config, &i80_bus)); /* 新建80并口总线 */ @@ -293,7 +296,7 @@ void lcd_init(lcd_cfg_t lcd_config) /* 80并口配置 */ .cs_gpio_num = lcd_dev.cs, .pclk_hz = (10 * 1000 * 1000), - .trans_queue_depth = 10, + .trans_queue_depth = 15, .dc_levels = { .dc_idle_level = 0, .dc_cmd_level = 0, @@ -327,4 +330,6 @@ void lcd_init(lcd_cfg_t lcd_config) ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); /* 启动屏幕 */ lcd_clear(WHITE); /* 默认填充白色 */ LCD_BL(1); /* 打开背光 */ + + lcd_dma_buffer = esp_lcd_i80_alloc_draw_buffer(io_handle, lcd_dev.pwidth * sizeof(uint16_t), MALLOC_CAP_DMA); } diff --git a/components/hal_driver/lcd.h b/components/hal_driver/lcd.h index 4c4c1f2..c619f0d 100644 --- a/components/hal_driver/lcd.h +++ b/components/hal_driver/lcd.h @@ -104,6 +104,7 @@ typedef struct _lcd_config_t /* 导出相关变量 */ extern lcd_obj_t lcd_dev; extern esp_lcd_panel_handle_t panel_handle; /* LCD句柄 */ +extern uint16_t *lcd_dma_buffer; /* lcd相关函数 */ void lcd_init(lcd_cfg_t lcd_config); /* 初始化lcd */ void lcd_clear(uint16_t color); /* 清除屏幕 */ diff --git a/src/app.rs b/src/app.rs index 7b5d6d0..0463c93 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use tokio::sync::mpsc; use crate::{ audio::{self, AudioEvent, EventRx}, protocol::{self, ServerEvent}, + ui::DisplayTargetDrive, ws::Server, }; @@ -159,11 +160,12 @@ const SPEED_LIMIT: f64 = 1.0; const INTERNAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); const NORMAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60 * 5); -pub async fn main_work<'d>( +pub async fn main_work<'d, const N: usize>( mut server: Server, player_tx: audio::PlayerTx, mut evt_rx: EventRx, - mut gui: crate::ui::ChatUI, + framebuffer: &mut crate::boards::ui::DisplayBuffer, + gui: &mut crate::boards::ui::ChatUI, ) -> anyhow::Result<()> { #[derive(PartialEq, Eq)] enum State { @@ -175,7 +177,8 @@ pub async fn main_work<'d>( gui.set_state("Idle".to_string()); gui.set_text("".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; let mut state = State::Idle; @@ -208,7 +211,8 @@ pub async fn main_work<'d>( if state == State::Listening { state = State::Idle; gui.set_state("Idle".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; server.close().await?; } else { server.reconnect_with_retry(3).await?; @@ -228,7 +232,8 @@ pub async fn main_work<'d>( state = State::Listening; gui.set_state("Listening...".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } } Event::Event(Event::K0_) => { @@ -237,7 +242,8 @@ pub async fn main_work<'d>( allow_interrupt = !allow_interrupt; log::info!("Set allow_interrupt to {}", allow_interrupt); gui.set_state(format!("Interrupt: {}", allow_interrupt)); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } } Event::Event(Event::VOL_UP) => { @@ -250,7 +256,8 @@ pub async fn main_work<'d>( .map_err(|e| anyhow::anyhow!("Error sending volume set: {e:?}"))?; log::info!("Volume set to {}", vol); gui.set_state(format!("Volume: {}", vol)); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::Event(Event::VOL_DOWN) => { vol -= 1; @@ -262,7 +269,8 @@ pub async fn main_work<'d>( .map_err(|e| anyhow::anyhow!("Error sending volume set: {e:?}"))?; log::info!("Volume set to {}", vol); gui.set_state(format!("Volume: {}", vol)); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::Event(Event::VOL_SWITCH) => { vol -= 1; @@ -274,7 +282,8 @@ pub async fn main_work<'d>( .map_err(|e| anyhow::anyhow!("Error sending volume set: {e:?}"))?; log::info!("Volume set to {}", vol); gui.set_state(format!("Volume: {}", vol)); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::Event(Event::YES | Event::K1) => {} Event::Event(Event::IDLE) => { @@ -282,7 +291,8 @@ pub async fn main_work<'d>( if state == State::Listening { state = State::Idle; gui.set_state("Idle".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; server.close().await?; } } @@ -294,8 +304,9 @@ pub async fn main_work<'d>( log::info!("Received event: {:?}", evt); } Event::Vowel(v) => { - gui.set_header(v as usize); - gui.flush()?; + gui.set_avatar_index(v as usize); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::MicAudioChunk(data) if state == State::Listening => { submit_audio += data.len() as f32 / 16000.0; @@ -326,7 +337,8 @@ pub async fn main_work<'d>( if submit_audio > 0.6 { state = State::Listening; gui.set_state("Listening...".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; server.reconnect_with_retry(3).await?; @@ -384,7 +396,8 @@ pub async fn main_work<'d>( wait_notify = false; state = State::Waiting; gui.set_state("Waiting...".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } } Event::MicInterruptWaitTimeout => { @@ -412,19 +425,22 @@ pub async fn main_work<'d>( wait_notify = false; state = State::Waiting; gui.set_state("Waiting...".to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::ServerEvent(ServerEvent::ASR { text }) => { log::info!("Received ASR: {:?}", text); state = State::Speaking; gui.set_state("ASR".to_string()); gui.set_asr(text.trim().to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::ServerEvent(ServerEvent::Action { action }) => { log::info!("Received action"); gui.set_state(format!("Action: {}", action)); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } Event::ServerEvent(ServerEvent::StartAudio { text }) => { start_audio = true; @@ -435,7 +451,8 @@ pub async fn main_work<'d>( log::info!("Received audio start: {:?}", text); gui.set_state(format!("[{:.2}x]|Speaking...", speed)); gui.set_text(text.trim().to_string()); - gui.flush()?; + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; player_tx .send(AudioEvent::StartSpeech) .map_err(|e| anyhow::anyhow!("Error sending start: {e:?}"))?; @@ -460,7 +477,8 @@ pub async fn main_work<'d>( { log::error!("Error sending audio chunk: {:?}", e); gui.set_state("Error on audio chunk".to_string()); - gui.flush().unwrap(); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } } else { recv_audio_buffer.extend_from_slice(&data); @@ -480,7 +498,8 @@ pub async fn main_work<'d>( if let Err(e) = player_tx.send(AudioEvent::SpeechChunki16(recv_audio_buffer)) { log::error!("Error sending audio chunk: {:?}", e); gui.set_state("Error on audio chunk".to_string()); - gui.flush().unwrap(); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } recv_audio_buffer = Vec::with_capacity(8192); } @@ -488,7 +507,8 @@ pub async fn main_work<'d>( if let Err(e) = player_tx.send(AudioEvent::EndSpeech(notify.clone())) { log::error!("Error sending audio chunk: {:?}", e); gui.set_state("Error on audio chunk".to_string()); - gui.flush().unwrap(); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } if need_compute { @@ -507,7 +527,8 @@ pub async fn main_work<'d>( log::info!("Received request end"); state = State::Listening; gui.set_state("Listening...".to_string()); - gui.flush().unwrap(); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; recv_audio_buffer.clear(); } Event::ServerEvent(ServerEvent::HelloStart) => { @@ -526,7 +547,8 @@ pub async fn main_work<'d>( if let Err(_) = player_tx.send(AudioEvent::SetHello(hello_wav)) { log::error!("Error sending hello end"); gui.set_state("Error on hello end".to_string()); - gui.flush().unwrap(); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } hello_wav = Vec::with_capacity(1024 * 30); init_hello = true; @@ -550,7 +572,8 @@ pub async fn main_work<'d>( state = State::Idle; gui.set_state("Idle".to_string()); gui.set_text(format!("Server URL updated:\n{}", server.url)); - gui.flush().unwrap(); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } } } diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index 70e4212..14417ed 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -150,7 +150,496 @@ pub fn lcd_init( Ok(()) } +pub mod ui { + use super::*; + + use embedded_graphics::{ + framebuffer::{buffer_size, Framebuffer}, + image::GetPixel, + pixelcolor::raw::{LittleEndian, RawU16}, + prelude::*, + primitives::{PrimitiveStyleBuilder, Rectangle}, + text::{Alignment, Text}, + Drawable, + }; + use u8g2_fonts::U8g2TextStyle; + + use crate::ui::{ColorFormat, DisplayTargetDrive, DynamicImage, ImageArea}; + + type FrameBufferChunk8x12 = Framebuffer< + ColorFormat, + RawU16, + LittleEndian, + 8, + 12, + { buffer_size::(8, 12) }, + >; + + pub type DisplayBuffer = BoxFrameBuffer; + + type FrameMask = [u8; (DISPLAY_WIDTH / 8) * (DISPLAY_HEIGHT / 12)]; + + pub struct BoxFrameBuffer { + buffers: Vec, //[FrameBufferChunk8x12; (DISPLAY_WIDTH / 8) * (DISPLAY_HEIGHT / 12)], + background_buffers: Vec, //[FrameBufferChunk8x12; (DISPLAY_WIDTH / 8) * (DISPLAY_HEIGHT / 12)], + diff_indexs: Vec, + resume_indexs: Vec, + draw_mask: FrameMask, + } + + impl Dimensions for BoxFrameBuffer { + fn bounding_box(&self) -> Rectangle { + Rectangle::new( + Point::new(0, 0), + Size::new(DISPLAY_WIDTH as u32, DISPLAY_HEIGHT as u32), + ) + } + } + + impl DrawTarget for BoxFrameBuffer { + type Color = ColorFormat; + type Error = core::convert::Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + for embedded_graphics::Pixel(coord, color) in pixels { + if coord.x < 0 + || coord.x >= DISPLAY_WIDTH as i32 + || coord.y < 0 + || coord.y >= DISPLAY_HEIGHT as i32 + { + continue; + } + + let x = coord.x as usize; + let y = coord.y as usize; + + let chunk_x = x / 8; + let chunk_y = y / 12; + let chunk_index = chunk_y * (DISPLAY_WIDTH / 8) + chunk_x; + + let local_x = x % 8; + let local_y = y % 12; + + if self.draw_mask[chunk_index] == 0 { + self.diff_indexs.push(chunk_index); + self.draw_mask[chunk_index] = 1; + } + + self.buffers[chunk_index].set_pixel( + embedded_graphics::prelude::Point::new(local_x as i32, local_y as i32), + color, + ); + } + + Ok(()) + } + } + + impl GetPixel for BoxFrameBuffer { + type Color = ColorFormat; + + fn pixel(&self, point: Point) -> Option { + if point.x < 0 + || point.x >= DISPLAY_WIDTH as i32 + || point.y < 0 + || point.y >= DISPLAY_HEIGHT as i32 + { + return None; + } + + let x = point.x as usize; + let y = point.y as usize; + + let chunk_x = x / 8; + let chunk_y = y / 12; + let chunk_index = chunk_y * (DISPLAY_WIDTH / 8) + chunk_x; + + let local_x = x % 8; + let local_y = y % 12; + + self.buffers[chunk_index].pixel(embedded_graphics::prelude::Point::new( + local_x as i32, + local_y as i32, + )) + } + } + + impl DisplayTargetDrive for BoxFrameBuffer { + fn new(color: ColorFormat) -> Self { + let mut s = Self { + buffers: vec![Framebuffer::new(); (DISPLAY_WIDTH / 8) * (DISPLAY_HEIGHT / 12)], + background_buffers: vec![ + Framebuffer::new(); + (DISPLAY_WIDTH / 8) * (DISPLAY_HEIGHT / 12) + ], + diff_indexs: Vec::new(), + resume_indexs: Vec::new(), + draw_mask: [0; (DISPLAY_WIDTH / 8) * (DISPLAY_HEIGHT / 12)], + }; + + for buffer in s.buffers.iter_mut() { + buffer.clear(color).unwrap(); + } + + for buffer in s.background_buffers.iter_mut() { + buffer.clear(color).unwrap(); + } + + s + } + + fn flush(&mut self) -> anyhow::Result<()> { + let now = std::time::Instant::now(); + unsafe { + let panel_handle = std::mem::transmute(esp_idf_svc::sys::hal_driver::panel_handle); + + for i in self.diff_indexs.iter().chain(self.resume_indexs.iter()) { + let i = *i; + let x_start = ((i % (DISPLAY_WIDTH / 8)) * 8) as i32; + let y_start = ((i / (DISPLAY_WIDTH / 8)) * 12) as i32; + let x_end = x_start + 8; + let y_end = y_start + 12; + + // DEBUG + // self.buffers[i].clear(ColorFormat::CSS_GOLD).unwrap(); + + let color_data = self.buffers[i].data(); + let size = color_data.len(); + + let lcd_dma: *mut u8 = esp_idf_svc::sys::hal_driver::lcd_dma_buffer as *mut u8; + lcd_dma.copy_from(color_data.as_ptr() as *const u8, size); + + let e = esp_idf_svc::sys::esp_lcd_panel_draw_bitmap( + panel_handle, + x_start, + y_start, + x_end, + y_end, + lcd_dma as *const _, + ); + if e != 0 { + log::warn!("flush_display error: {}", e); + } + + if self.draw_mask[i] != 0 { + self.draw_mask[i] = 0; + self.buffers[i].clone_from(&self.background_buffers[i]); + } + } + + log::info!( + "Display flush took {:?} for {} chunks, {} resumed", + now.elapsed(), + self.diff_indexs.len(), + self.resume_indexs.len() + ); + + self.diff_indexs.clear(); + self.resume_indexs.clear(); + } + Ok(()) + } + + fn fix_background(&mut self) -> anyhow::Result<()> { + self.background_buffers.clone_from(&self.buffers); + Ok(()) + } + } + + impl BoxFrameBuffer { + fn resume_chunks(&mut self, chunks: &[usize]) { + for &i in chunks { + if self.draw_mask[i] == 0 { + self.resume_indexs.push(i); + } + } + } + } + + pub struct ChatUI { + state_text: String, + state_text_updated: bool, + state_chunks: Vec, + + asr_text: String, + asr_text_updated: bool, + asr_text_chunks: Vec, + + content: String, + content_updated: bool, + content_chunks: Vec, + + avatar: DynamicImage, + avatar_updated: bool, + avatar_chunks: Vec, + } + + impl ChatUI { + pub fn new(avatar: DynamicImage) -> Self { + Self { + state_text: String::new(), + state_text_updated: false, + state_chunks: Vec::new(), + + asr_text: String::new(), + asr_text_updated: false, + asr_text_chunks: Vec::new(), + + content: String::new(), + content_updated: false, + content_chunks: Vec::new(), + + avatar: avatar, + avatar_updated: false, + avatar_chunks: Vec::new(), + } + } + + pub fn set_state(&mut self, text: String) { + if self.state_text != text { + self.state_text = text; + self.state_text_updated = true; + } + } + + pub fn set_asr(&mut self, text: String) { + if self.asr_text != text { + self.asr_text = text; + self.asr_text_updated = true; + } + } + + pub fn set_text(&mut self, text: String) { + if self.content != text { + self.content = text; + self.content_updated = true; + } + } + + pub fn set_avatar_index(&mut self, index: usize) { + self.avatar.set_index(index); + self.avatar_updated = true; + } + + pub fn clear_update_flags(&mut self) { + self.state_text_updated = false; + self.asr_text_updated = false; + self.content_updated = false; + self.avatar_updated = false; + } + + pub fn render_to_target(&mut self, target: &mut BoxFrameBuffer) -> anyhow::Result<()> { + let bounding_box = target.bounding_box(); + let (state_area_box, asr_area_box, content_area_box) = Self::layout(bounding_box); + + log::info!( + "draw ChatUI {} {} {} {}", + self.state_text_updated, + self.asr_text_updated, + self.content_updated, + self.avatar_updated + ); + + let mut start_i = 0; + + if self.state_text_updated { + Text::with_alignment( + &self.state_text, + state_area_box.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_LIGHT_CYAN, + ), + Alignment::Center, + ) + .draw(target)?; + target.resume_chunks(&self.state_chunks); + self.state_chunks = target.diff_indexs.clone(); + start_i = self.state_chunks.len(); + } + + if self.asr_text_updated { + Text::with_alignment( + &self.asr_text, + asr_area_box.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_WHEAT, + ), + Alignment::Center, + ) + .draw(target)?; + target.resume_chunks(&self.asr_text_chunks); + self.asr_text_chunks = target.diff_indexs[start_i..].to_vec(); + start_i += self.asr_text_chunks.len(); + } + + if self.content_updated { + let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() + .height_mode(embedded_text::style::HeightMode::FitToText) + .alignment(embedded_text::alignment::HorizontalAlignment::Center) + .line_height(embedded_graphics::text::LineHeight::Percent(120)) + .paragraph_spacing(16) + .build(); + + embedded_text::TextBox::with_textbox_style( + &self.content, + content_area_box, + crate::ui::MyTextStyle( + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy16_t_gb2312, + ColorFormat::CSS_WHEAT, + ), + 3, + ), + textbox_style, + ) + .draw(target)?; + target.resume_chunks(&self.content_chunks); + self.content_chunks = target.diff_indexs[start_i..].to_vec(); + start_i += self.content_chunks.len(); + } + + if self.avatar_updated { + self.avatar.render(target)?; + target.resume_chunks(&self.avatar_chunks); + self.avatar_chunks = target.diff_indexs[start_i..].to_vec(); + } + + self.clear_update_flags(); + + Ok(()) + } + + pub fn layout(bounding_box: Rectangle) -> (Rectangle, Rectangle, Rectangle) { + let state_area_box = Rectangle::new( + bounding_box.top_left + Point::new(96, 0), + Size::new(bounding_box.size.width - 96, 32), + ); + + let asr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(96, 32), + Size::new(bounding_box.size.width - 96, 64), + ); + + let content_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, 32 + 64), + Size::new(bounding_box.size.width, bounding_box.size.height - 32 - 64), + ); + + (state_area_box, asr_area_box, content_area_box) + } + } + + pub fn new_chat_ui(target: &mut BoxFrameBuffer) -> anyhow::Result> { + let bounding_box = target.bounding_box(); + let avatar_area_box = Rectangle::new(bounding_box.top_left, Size::new(96, 96)); + + let (state_area_box, asr_area_box, content_area_box) = ChatUI::::layout(bounding_box); + let state_style = PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_BLUE) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_BLUE) + .build(); + + let pixels = crate::ui::get_background_pixels(target, state_area_box, state_style, 0.5); + target.draw_iter(pixels)?; + + let asr_style = PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_CYAN) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_CYAN) + .build(); + + let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.15); + target.draw_iter(pixels)?; + + let content_style = PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_BLACK) + .stroke_width(5) + .fill_color(ColorFormat::CSS_BLACK) + .build(); + let pixels = + crate::ui::get_background_pixels(target, content_area_box, content_style, 0.25); + target.draw_iter(pixels)?; + + target.background_buffers.clone_from(&target.buffers); + + target.flush()?; + + let avatar = DynamicImage::new_from_gif(avatar_area_box, crate::ui::AVATAR_GIF)?; + Ok(ChatUI::new(avatar)) + } + + pub struct ConfiguresUI { + qr_area: ImageArea, + info: String, + } + + impl ConfiguresUI { + pub fn new( + bounding_box: Rectangle, + qr_content: &str, + info: String, + ) -> anyhow::Result { + let height = bounding_box.size.height; + let qr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, height as i32 / 3), + Size::new(bounding_box.size.width, 2 * height / 3), + ); + + Ok(Self { + qr_area: ImageArea::new_from_qr_code(qr_area_box, qr_content)?, + info: info.to_string(), + }) + } + + pub fn set_info(&mut self, info: String) { + self.info = info; + } + } + + impl Drawable for ConfiguresUI { + type Color = ColorFormat; + + type Output = (); + + fn draw(&self, target: &mut D) -> Result + where + D: DrawTarget, + { + let info_area_box = Rectangle::new( + target.bounding_box().top_left, + Size::new( + target.bounding_box().size.width, + target.bounding_box().size.height / 3, + ), + ); + + Text::with_alignment( + &self.info, + info_area_box.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_WHEAT, + ), + Alignment::Center, + ) + .draw(target)?; + + target.draw_iter(self.qr_area.image_data.iter().cloned())?; + + Ok(()) + } + } +} + pub fn flush_display(color_data: &[u8], x_start: i32, y_start: i32, x_end: i32, y_end: i32) -> i32 { + // if write area size > 80, lcd will display wrong data + debug_assert_eq!( x_end - x_start, DISPLAY_WIDTH as i32, diff --git a/src/boards/mod.rs b/src/boards/mod.rs index d252448..64b6c50 100644 --- a/src/boards/mod.rs +++ b/src/boards/mod.rs @@ -217,3 +217,350 @@ pub fn set_backlight<'d>( ledc_driver.set_duty(duty)?; Ok(()) } + +#[cfg(feature = "base_ui")] +pub mod ui { + use super::*; + + use embedded_graphics::{ + framebuffer::{buffer_size, Framebuffer}, + image::GetPixel, + pixelcolor::raw::{LittleEndian, RawU16}, + prelude::*, + primitives::{PrimitiveStyleBuilder, Rectangle}, + text::{Alignment, Text}, + Drawable, + }; + use u8g2_fonts::U8g2TextStyle; + + use crate::ui::{ColorFormat, DisplayTargetDrive, DynamicImage, ImageArea}; + + pub type DisplayBuffer = FrameBuffer; + + type Framebuffer_ = Framebuffer< + ColorFormat, + RawU16, + LittleEndian, + DISPLAY_WIDTH, + DISPLAY_HEIGHT, + { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, + >; + pub struct FrameBuffer { + buffers: Box, + background_buffers: Box, + } + + impl Dimensions for FrameBuffer { + fn bounding_box(&self) -> Rectangle { + Rectangle::new( + Point::new(0, 0), + Size::new(DISPLAY_WIDTH as u32, DISPLAY_HEIGHT as u32), + ) + } + } + + impl DrawTarget for FrameBuffer { + type Color = ColorFormat; + type Error = core::convert::Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + self.buffers.draw_iter(pixels)?; + Ok(()) + } + } + + impl GetPixel for FrameBuffer { + type Color = ColorFormat; + + fn pixel(&self, point: Point) -> Option { + self.buffers.pixel(point) + } + } + + impl DisplayTargetDrive for FrameBuffer { + fn new(color: ColorFormat) -> Self { + let mut s = Self { + buffers: Box::new(Framebuffer::new()), + background_buffers: Box::new(Framebuffer::new()), + }; + + s.buffers.clear(color).unwrap(); + s.background_buffers.clear(color).unwrap(); + + s + } + fn flush(&mut self) -> anyhow::Result<()> { + let bounding_box = self.bounding_box(); + let x_start = bounding_box.top_left.x as i32; + let y_start = bounding_box.top_left.y as i32; + let x_end = bounding_box.top_left.x + bounding_box.size.width as i32; + let y_end = bounding_box.top_left.y + bounding_box.size.height as i32; + + let e = flush_display(self.buffers.data(), x_start, y_start, x_end, y_end); + if e != 0 { + return Err(anyhow::anyhow!("Failed to flush display: error code {}", e)); + } + + self.buffers.clone_from(&self.background_buffers); + + Ok(()) + } + + fn fix_background(&mut self) -> anyhow::Result<()> { + self.background_buffers.clone_from(&self.buffers); + Ok(()) + } + } + + const AVATAR_SIZE: u32 = 96; + pub struct ChatUI { + state_text: String, + + asr_text: String, + + content: String, + + avatar: DynamicImage, + } + + impl ChatUI { + pub fn new(avatar: DynamicImage) -> Self { + Self { + state_text: String::new(), + asr_text: String::new(), + content: String::new(), + avatar: avatar, + } + } + + pub fn set_state(&mut self, text: String) { + if self.state_text != text { + self.state_text = text; + } + } + + pub fn set_asr(&mut self, text: String) { + if self.asr_text != text { + self.asr_text = text; + } + } + + pub fn set_text(&mut self, text: String) { + if self.content != text { + self.content = text; + } + } + + pub fn set_avatar_index(&mut self, index: usize) { + self.avatar.set_index(index); + } + + pub fn render_to_target(&mut self, target: &mut FrameBuffer) -> anyhow::Result<()> { + self.draw(target) + .map_err(|e| anyhow::anyhow!("Failed to draw ChatUI: {:?}", e))?; + + Ok(()) + } + + pub fn layout(bounding_box: Rectangle) -> (Rectangle, Rectangle, Rectangle) { + let state_area_box = Rectangle::new( + bounding_box.top_left, + Size::new(bounding_box.size.width, 32), + ); + + let asr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, 32), + Size::new(bounding_box.size.width, 32), + ); + + let content_height = bounding_box.size.height - 64; + + let content_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, 64), + Size::new(bounding_box.size.width, content_height), + ); + + (state_area_box, asr_area_box, content_area_box) + } + } + + impl Drawable for ChatUI { + type Color = ColorFormat; + + type Output = (); + + fn draw(&self, target: &mut D) -> Result + where + D: DrawTarget, + { + let bounding_box = target.bounding_box(); + + self.avatar.render(target)?; + + let (state_area_box, asr_area_box, content_area_box) = Self::layout(bounding_box); + + { + Text::with_alignment( + &self.state_text, + state_area_box.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_LIGHT_CYAN, + ), + Alignment::Center, + ) + .draw(target)?; + } + + { + Text::with_alignment( + &self.asr_text, + asr_area_box.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_WHEAT, + ), + Alignment::Center, + ) + .draw(target)?; + } + + { + let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() + .height_mode(embedded_text::style::HeightMode::FitToText) + .alignment(embedded_text::alignment::HorizontalAlignment::Center) + .line_height(embedded_graphics::text::LineHeight::Percent(120)) + .paragraph_spacing(16) + .build(); + + embedded_text::TextBox::with_textbox_style( + &self.content, + content_area_box, + crate::ui::MyTextStyle( + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy16_t_gb2312, + ColorFormat::CSS_WHEAT, + ), + 3, + ), + textbox_style, + ) + .draw(target)?; + } + + Ok(()) + } + } + + pub fn new_chat_ui(target: &mut FrameBuffer) -> anyhow::Result> { + let bounding_box = target.bounding_box(); + + let header_area_box = Rectangle::new( + bounding_box.center() + - Point { + x: AVATAR_SIZE as i32 / 2, + y: AVATAR_SIZE as i32 / 2, + }, + Size::new(AVATAR_SIZE, AVATAR_SIZE), + ); + + let (state_area_box, asr_area_box, content_area_box) = ChatUI::::layout(bounding_box); + let state_style = PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_BLUE) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_BLUE) + .build(); + + let pixels = crate::ui::get_background_pixels(target, state_area_box, state_style, 0.5); + target.draw_iter(pixels)?; + + let asr_style = PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_DARK_CYAN) + .stroke_width(1) + .fill_color(ColorFormat::CSS_DARK_CYAN) + .build(); + + let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.15); + target.draw_iter(pixels)?; + + let content_style = PrimitiveStyleBuilder::new() + .stroke_color(ColorFormat::CSS_BLACK) + .stroke_width(5) + .fill_color(ColorFormat::CSS_BLACK) + .build(); + let pixels = + crate::ui::get_background_pixels(target, content_area_box, content_style, 0.25); + target.draw_iter(pixels)?; + + target.background_buffers.clone_from(&target.buffers); + + let avatar = DynamicImage::new_from_gif(header_area_box, crate::ui::AVATAR_GIF)?; + + Ok(ChatUI::new(avatar)) + } + + pub struct ConfiguresUI { + qr_area: ImageArea, + info: String, + } + + impl ConfiguresUI { + pub fn new( + bounding_box: Rectangle, + qr_content: &str, + info: String, + ) -> anyhow::Result { + let height = bounding_box.size.height; + let qr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, height as i32 / 3), + Size::new(bounding_box.size.width, 2 * height / 3), + ); + + Ok(Self { + qr_area: ImageArea::new_from_qr_code(qr_area_box, qr_content)?, + info, + }) + } + + pub fn set_info(&mut self, info: String) { + self.info = info; + } + } + + impl Drawable for ConfiguresUI { + type Color = ColorFormat; + + type Output = (); + + fn draw(&self, target: &mut D) -> Result + where + D: DrawTarget, + { + let info_area_box = Rectangle::new( + target.bounding_box().top_left, + Size::new( + target.bounding_box().size.width, + target.bounding_box().size.height / 3, + ), + ); + + Text::with_alignment( + &self.info, + info_area_box.center(), + U8g2TextStyle::new( + u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, + ColorFormat::CSS_WHEAT, + ), + Alignment::Center, + ) + .draw(target)?; + + target.draw_iter(self.qr_area.image_data.iter().cloned())?; + + Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs index d3be8a7..826094a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,13 @@ use std::sync::{Arc, Mutex}; +use embedded_graphics::{ + prelude::{Dimensions, RgbColor}, + Drawable, +}; use esp_idf_svc::eventloop::EspSystemEventLoop; +use crate::ui::DisplayTargetDrive; + mod app; mod audio; mod bt; @@ -107,27 +113,31 @@ fn main() -> anyhow::Result<()> { crate::start_hal!(peripherals, evt_tx); - ui::background(&setting.background_gif.0, boards::flush_display).unwrap(); - - let start_ui = if setting.background_gif.0.is_empty() { - log::info!("No background GIF found, using default start UI"); - ui::StartUI { - flush_fn: boards::flush_display, - display_target: ui::new_display_target(), - } - } else { - // ui::StartUI::new_with_gif( - // ui::new_display_target(), - // boards::flush_display, - // &setting.background_gif.0, - // )? - ui::StartUI::new_with_png( - ui::new_display_target(), - boards::flush_display, - ui::LM_PNG, - 3_000, - )? - }; + // ui::background(&setting.background_gif.0, boards::flush_display).unwrap(); + let mut framebuffer = Box::new(boards::ui::DisplayBuffer::new(ui::ColorFormat::WHITE)); + framebuffer.flush()?; + + // let start_ui = if setting.background_gif.0.is_empty() { + // log::info!("No background GIF found, using default start UI"); + // ui::StartUI { + // flush_fn: boards::flush_display, + // display_target: ui::new_display_target(), + // } + // } else { + // // ui::StartUI::new_with_gif( + // // ui::new_display_target(), + // // boards::flush_display, + // // &setting.background_gif.0, + // // )? + // ui::StartUI::new_with_png( + // ui::new_display_target(), + // boards::flush_display, + // ui::LM_PNG, + // 3_000, + // )? + // }; + + crate::ui::display_gif(framebuffer.as_mut(), &setting.background_gif.0).unwrap(); // Configures the button let mut button = esp_idf_svc::hal::gpio::PinDriver::input(peripherals.pins.gpio0)?; @@ -165,7 +175,7 @@ fn main() -> anyhow::Result<()> { let need_init = button.is_low() || setting.need_init(); if need_init { - let mut config_ui = ui::new_config_ui(start_ui, "https://echokit.dev/setup/")?; + // let mut config_ui = ui::new_config_ui(start_ui, "https://echokit.dev/setup/")?; let esp_wifi = esp_idf_svc::wifi::EspWifi::new(peripherals.modem, sysloop, None)?; let mac = esp_wifi.sta_netif().get_mac()?; @@ -182,8 +192,10 @@ fn main() -> anyhow::Result<()> { let version = env!("CARGO_PKG_VERSION"); - config_ui.set_info( format!("Goto https://echokit.dev/setup/ to set up the device.\nDevice Name: EchoKit-{}\nVersion: {}", ble_addr, version)); - config_ui.flush()?; + let mut config_ui = boards::ui::ConfiguresUI::new(framebuffer.bounding_box(), "https://echokit.dev/setup/", format!("Goto https://echokit.dev/setup/ to set up the device.\nDevice Name: EchoKit-{}\nVersion: {}", ble_addr, version)).unwrap(); + + config_ui.draw(framebuffer.as_mut())?; + framebuffer.flush()?; #[cfg(feature = "boards")] { @@ -216,7 +228,8 @@ fn main() -> anyhow::Result<()> { let mut setting = setting.lock().unwrap(); if setting.0.background_gif.1 { config_ui.set_info("Testing background GIF...".to_string()); - config_ui.flush()?; + config_ui.draw(framebuffer.as_mut())?; + framebuffer.flush()?; let mut new_gif = Vec::new(); std::mem::swap(&mut setting.0.background_gif.0, &mut new_gif); @@ -225,7 +238,8 @@ fn main() -> anyhow::Result<()> { log::info!("Background GIF set from NVS"); config_ui.set_info("Background GIF set OK".to_string()); - config_ui.flush()?; + config_ui.draw(framebuffer.as_mut())?; + framebuffer.flush()?; setting .1 @@ -239,10 +253,11 @@ fn main() -> anyhow::Result<()> { unsafe { esp_idf_svc::sys::esp_restart() } } - let mut chat_ui = ui::new_chat_ui(start_ui)?; + let mut chat_ui = boards::ui::new_chat_ui::<4>(framebuffer.as_mut())?; chat_ui.set_state("Connecting to wifi...".to_string()); - chat_ui.flush()?; + chat_ui.render_to_target(framebuffer.as_mut())?; + framebuffer.flush()?; let _wifi = network::wifi( &setting.ssid, @@ -253,7 +268,9 @@ fn main() -> anyhow::Result<()> { if _wifi.is_err() { chat_ui.set_state("Failed to connect to wifi".to_string()); chat_ui.set_text("Press K0 to open settings".to_string()); - chat_ui.flush()?; + chat_ui.render_to_target(framebuffer.as_mut())?; + framebuffer.flush()?; + b.block_on(button.wait_for_falling_edge()).unwrap(); nvs.set_u8("state", 1).unwrap(); unsafe { esp_idf_svc::sys::esp_restart() } @@ -270,7 +287,8 @@ fn main() -> anyhow::Result<()> { chat_ui.set_state("Connecting to server...".to_string()); chat_ui.set_text("".to_string()); - chat_ui.flush()?; + chat_ui.render_to_target(framebuffer.as_mut())?; + framebuffer.flush()?; log_heap(); @@ -282,7 +300,8 @@ fn main() -> anyhow::Result<()> { let server = b.block_on(ws::Server::new(dev_id, setting.server_url)); if server.is_err() { log::info!("Failed to connect to server: {:?}", server.err()); - chat_ui.flush()?; + chat_ui.render_to_target(framebuffer.as_mut())?; + framebuffer.flush()?; b.block_on(button.wait_for_falling_edge()).unwrap(); nvs.set_u8("state", 1).unwrap(); unsafe { esp_idf_svc::sys::esp_restart() } @@ -292,7 +311,7 @@ fn main() -> anyhow::Result<()> { crate::start_audio_workers!(peripherals, rx1, evt_tx.clone(), &b); - let ws_task = app::main_work(server, tx1, evt_rx, chat_ui); + let ws_task = app::main_work(server, tx1, evt_rx, &mut framebuffer, &mut chat_ui); b.spawn(async move { loop { diff --git a/src/ui.rs b/src/ui.rs index 47f852b..dcc81f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -80,6 +80,10 @@ pub fn background(gif: &[u8], f: FlushDisplayFn) -> anyhow::Result<()> { DISPLAY_HEIGHT as _, ); + let e = now.elapsed(); + + log::info!("GIF frame rendered in {:?}", e); + std::thread::sleep(std::time::Instant::now() - (now + delay)); } @@ -88,7 +92,7 @@ pub fn background(gif: &[u8], f: FlushDisplayFn) -> anyhow::Result<()> { // TextRenderer + CharacterStyle #[derive(Debug, Clone)] -struct MyTextStyle(U8g2TextStyle, i32); +pub struct MyTextStyle(pub U8g2TextStyle, pub i32); impl TextRenderer for MyTextStyle { type Color = ColorFormat; @@ -171,7 +175,106 @@ type DisplayTarget = Framebuffer< { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, >; -fn alpha_mix(source: ColorFormat, target: ColorFormat, alpha: f32) -> ColorFormat { +pub trait DisplayTargetDrive: + DrawTarget + GetPixel +{ + fn new(color: ColorFormat) -> Self; + fn flush(&mut self) -> anyhow::Result<()>; + fn fix_background(&mut self) -> anyhow::Result<()>; +} + +pub fn display_gif( + display_target: &mut D, + gif: &[u8], +) -> anyhow::Result<()> { + use image::AnimationDecoder; + let img_gif = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(gif))?; + + let mut frames = img_gif.into_frames(); + let mut ff = frames.next(); + + loop { + if ff.is_none() { + break; + } + + let frame = ff.unwrap()?; + + let delay = frame.delay(); + + let img = frame.into_buffer(); + let pixels = img.enumerate_pixels().map(|(x, y, p)| { + let (x, y) = if p[3] == 0 { + (-1, -1) + } else { + (x as i32, y as i32) + }; + + Pixel( + Point { x, y }, + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ), + ) + }); + + display_target + .draw_iter(pixels) + .map_err(|_| anyhow::anyhow!("Failed to draw GIF frame"))?; + + let now = std::time::Instant::now(); + ff = frames.next(); + if ff.is_none() { + display_target.fix_background()?; + } + + display_target.flush()?; + + let delay = std::time::Duration::from(delay); + + std::thread::sleep(std::time::Instant::now() - (now + delay)); + } + + Ok(()) +} + +pub fn display_png( + display_target: &mut D, + png: &[u8], + timeout: std::time::Duration, +) -> anyhow::Result<()> { + let img_reader = + image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); + + let img = img_reader.decode().unwrap().to_rgb8(); + + let p = img.enumerate_pixels().map(|(x, y, p)| { + Pixel( + Point::new(x as i32, y as i32), + ColorFormat::new( + p[0] / (u8::MAX / ColorFormat::MAX_R), + p[1] / (u8::MAX / ColorFormat::MAX_G), + p[2] / (u8::MAX / ColorFormat::MAX_B), + ), + ) + }); + + display_target + .draw_iter(p) + .map_err(|_| anyhow::anyhow!("Failed to draw PNG image"))?; + + display_target.fix_background()?; + + display_target.flush()?; + + std::thread::sleep(timeout); + + Ok(()) +} + +pub fn alpha_mix(source: ColorFormat, target: ColorFormat, alpha: f32) -> ColorFormat { ColorFormat::new( ((1. - alpha) * source.r() as f32 + alpha * target.r() as f32) as u8, ((1. - alpha) * source.g() as f32 + alpha * target.g() as f32) as u8, @@ -235,7 +338,6 @@ impl qrcode::render::Canvas for QrCanvas { pub struct DisplayArea { area: Rectangle, - background: Vec>, text: String, render_fn: fn(&DisplayArea, &mut DisplayTarget) -> anyhow::Result<()>, } @@ -249,15 +351,14 @@ impl DisplayArea { ) -> Self { Self { area, - background, text, render_fn, } } } -pub fn get_background_pixels( - display: &DisplayTarget, +pub fn get_background_pixels>( + display: &T, area: Rectangle, background_style: PrimitiveStyle, alpha: f32, @@ -291,8 +392,8 @@ pub fn new_display_target() -> Box { pub struct ImageArea { area: Rectangle, - image_data: Vec>, - render_fn: fn(&ImageArea, &mut DisplayTarget) -> anyhow::Result<()>, + pub image_data: Vec>, + pub render_fn: fn(&ImageArea, &mut DisplayTarget) -> anyhow::Result<()>, } impl ImageArea { @@ -337,8 +438,8 @@ impl ImageArea { }) } - pub fn new_from_qr_code(area: Rectangle, qr_context: &str) -> anyhow::Result { - let code = qrcode::QrCode::new(qr_context).unwrap(); + pub fn new_from_qr_code(area: Rectangle, qr_content: &str) -> anyhow::Result { + let code = qrcode::QrCode::new(qr_content).unwrap(); let ((width, height), code_pixel) = code .render::() .quiet_zone(true) @@ -370,7 +471,10 @@ impl ImageArea { .collect(); Ok(Self { - area, + area: Rectangle { + top_left: area.top_left + Point::new(offset_x as i32, offset_y as i32), + size: Size::new(width, height), + }, image_data: pixels, render_fn: Self::default_render, }) @@ -427,7 +531,10 @@ impl DynamicImage { self.display_index = index % N; } - pub fn render(&self, display: &mut DisplayTarget) -> anyhow::Result<()> { + pub fn render>( + &self, + display: &mut D, + ) -> Result<(), D::Error> { display.draw_iter(self.image_data[self.display_index].iter().cloned())?; Ok(()) } @@ -642,7 +749,6 @@ pub fn new_chat_ui(start: StartUI) -> anyhow::Result { ), String::new(), |area, display| { - area.background.iter().cloned().draw(display)?; Text::with_alignment( &area.text, area.area.center(), @@ -676,7 +782,6 @@ pub fn new_chat_ui(start: StartUI) -> anyhow::Result { ), String::new(), |area, display| { - area.background.iter().cloned().draw(display)?; Text::with_alignment( &area.text, area.area.center(), @@ -711,7 +816,6 @@ pub fn new_chat_ui(start: StartUI) -> anyhow::Result { ), String::new(), |area, display| { - area.background.iter().cloned().draw(display)?; let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() .height_mode(embedded_text::style::HeightMode::FitToText) .alignment(embedded_text::alignment::HorizontalAlignment::Center) @@ -820,7 +924,6 @@ pub fn new_config_ui(start: StartUI, qr_content: &str) -> anyhow::Result Date: Mon, 12 Jan 2026 01:35:49 +0800 Subject: [PATCH 06/22] remove useless ui code --- src/main.rs | 23 +-- src/ui.rs | 560 +--------------------------------------------------- 2 files changed, 10 insertions(+), 573 deletions(-) diff --git a/src/main.rs b/src/main.rs index 826094a..993aa48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,30 +113,9 @@ fn main() -> anyhow::Result<()> { crate::start_hal!(peripherals, evt_tx); - // ui::background(&setting.background_gif.0, boards::flush_display).unwrap(); let mut framebuffer = Box::new(boards::ui::DisplayBuffer::new(ui::ColorFormat::WHITE)); framebuffer.flush()?; - // let start_ui = if setting.background_gif.0.is_empty() { - // log::info!("No background GIF found, using default start UI"); - // ui::StartUI { - // flush_fn: boards::flush_display, - // display_target: ui::new_display_target(), - // } - // } else { - // // ui::StartUI::new_with_gif( - // // ui::new_display_target(), - // // boards::flush_display, - // // &setting.background_gif.0, - // // )? - // ui::StartUI::new_with_png( - // ui::new_display_target(), - // boards::flush_display, - // ui::LM_PNG, - // 3_000, - // )? - // }; - crate::ui::display_gif(framebuffer.as_mut(), &setting.background_gif.0).unwrap(); // Configures the button @@ -234,7 +213,7 @@ fn main() -> anyhow::Result<()> { let mut new_gif = Vec::new(); std::mem::swap(&mut setting.0.background_gif.0, &mut new_gif); - let _ = ui::background(&new_gif, boards::flush_display); + crate::ui::display_gif(framebuffer.as_mut(), &new_gif).unwrap(); log::info!("Background GIF set from NVS"); config_ui.set_info("Background GIF set OK".to_string()); diff --git a/src/ui.rs b/src/ui.rs index dcc81f1..b484cfd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,18 +1,10 @@ use embedded_graphics::{ - framebuffer::{buffer_size, Framebuffer}, image::GetPixel, - pixelcolor::{ - raw::{LittleEndian, RawU16}, - Rgb565, - }, + pixelcolor::Rgb565, prelude::*, - primitives::{PrimitiveStyle, PrimitiveStyleBuilder, Rectangle}, - text::{ - renderer::{CharacterStyle, TextRenderer}, - Alignment, Text, - }, + primitives::{PrimitiveStyle, Rectangle}, + text::renderer::{CharacterStyle, TextRenderer}, }; -use embedded_text::TextBox; use u8g2_fonts::U8g2TextStyle; pub type ColorFormat = Rgb565; @@ -20,76 +12,9 @@ pub type ColorFormat = Rgb565; // pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/echokit.gif"); pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/ht.gif"); -use crate::boards::{DISPLAY_HEIGHT, DISPLAY_WIDTH}; - pub const LM_PNG: &[u8] = include_bytes!("../assets/lm_320x240.png"); pub const AVATAR_GIF: &[u8] = include_bytes!("../assets/xx.gif"); -pub type FlushDisplayFn = - fn(color_data: &[u8], x_start: i32, y_start: i32, x_end: i32, y_end: i32) -> i32; - -pub fn background(gif: &[u8], f: FlushDisplayFn) -> anyhow::Result<()> { - use image::AnimationDecoder; - - // Create a new framebuffer - let mut display = Box::new(Framebuffer::< - ColorFormat, - _, - LittleEndian, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, - >::new()); - display.clear(ColorFormat::WHITE)?; - - let img_gif = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(gif))?; - - for ff in img_gif.into_frames() { - let frame = ff?; - - let delay = frame.delay(); - - let img = frame.into_buffer(); - - for (x, y, p) in img.enumerate_pixels() { - if x >= display.size().width || y >= display.size().height || p[3] == 0 { - continue; - } - - display.set_pixel( - Point { - x: x as i32, - y: y as i32, - }, - ColorFormat::new( - p[0] / (u8::MAX / ColorFormat::MAX_R), - p[1] / (u8::MAX / ColorFormat::MAX_G), - p[2] / (u8::MAX / ColorFormat::MAX_B), - ), - ); - } - - let now = std::time::Instant::now(); - let delay = std::time::Duration::from(delay); - - f( - display.data(), - 0, - 0, - DISPLAY_WIDTH as _, - DISPLAY_HEIGHT as _, - ); - - let e = now.elapsed(); - - log::info!("GIF frame rendered in {:?}", e); - - std::thread::sleep(std::time::Instant::now() - (now + delay)); - } - - Ok(()) -} - // TextRenderer + CharacterStyle #[derive(Debug, Clone)] pub struct MyTextStyle(pub U8g2TextStyle, pub i32); @@ -166,15 +91,6 @@ impl CharacterStyle for MyTextStyle { } } -type DisplayTarget = Framebuffer< - ColorFormat, - RawU16, - LittleEndian, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, ->; - pub trait DisplayTargetDrive: DrawTarget + GetPixel { @@ -336,27 +252,6 @@ impl qrcode::render::Canvas for QrCanvas { } } -pub struct DisplayArea { - area: Rectangle, - text: String, - render_fn: fn(&DisplayArea, &mut DisplayTarget) -> anyhow::Result<()>, -} - -impl DisplayArea { - pub fn new_text_area( - area: Rectangle, - background: Vec>, - text: String, - render_fn: fn(&DisplayArea, &mut DisplayTarget) -> anyhow::Result<()>, - ) -> Self { - Self { - area, - text, - render_fn, - } - } -} - pub fn get_background_pixels>( display: &T, area: Rectangle, @@ -375,25 +270,8 @@ pub fn get_background_pixels>( .collect() } -pub fn new_display_target() -> Box { - let mut display_target = Box::new(Framebuffer::< - ColorFormat, - _, - LittleEndian, - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, - >::new()); - - display_target.clear(ColorFormat::WHITE).unwrap(); - - display_target -} - pub struct ImageArea { - area: Rectangle, pub image_data: Vec>, - pub render_fn: fn(&ImageArea, &mut DisplayTarget) -> anyhow::Result<()>, } impl ImageArea { @@ -401,11 +279,7 @@ impl ImageArea { let pixels: Vec> = area.points().map(|point| Pixel(point, color)).collect(); - Ok(Self { - area, - image_data: pixels, - render_fn: Self::default_render, - }) + Ok(Self { image_data: pixels }) } pub fn new_from_png(area: Rectangle, png_data: &[u8]) -> anyhow::Result { @@ -431,11 +305,7 @@ impl ImageArea { )); } - Ok(Self { - area, - image_data: pixels, - render_fn: Self::default_render, - }) + Ok(Self { image_data: pixels }) } pub fn new_from_qr_code(area: Rectangle, qr_content: &str) -> anyhow::Result { @@ -471,19 +341,13 @@ impl ImageArea { .collect(); Ok(Self { - area: Rectangle { - top_left: area.top_left + Point::new(offset_x as i32, offset_y as i32), - size: Size::new(width, height), - }, + // area: Rectangle { + // top_left: area.top_left + Point::new(offset_x as i32, offset_y as i32), + // size: Size::new(width, height), + // }, image_data: pixels, - render_fn: Self::default_render, }) } - - pub fn default_render(area: &Self, display: &mut DisplayTarget) -> anyhow::Result<()> { - display.draw_iter(area.image_data.iter().cloned())?; - Ok(()) - } } pub struct DynamicImage { @@ -539,409 +403,3 @@ impl DynamicImage { Ok(()) } } - -pub struct StartUI { - pub flush_fn: FlushDisplayFn, - pub display_target: Box, -} - -impl StartUI { - pub fn new_with_gif( - mut display_target: Box, - flush_fn: FlushDisplayFn, - gif: &[u8], - ) -> anyhow::Result { - use image::AnimationDecoder; - let img_gif = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(gif)).unwrap(); - - let frames = img_gif.into_frames(); - for ff in frames { - let frame = ff.unwrap(); - - let delay = frame.delay(); - - let img = frame.into_buffer(); - let pixels = img.enumerate_pixels().map(|(x, y, p)| { - let (x, y) = if p[3] == 0 { - (-1, -1) - } else { - (x as i32, y as i32) - }; - - Pixel( - Point { x, y }, - ColorFormat::new( - p[0] / (u8::MAX / ColorFormat::MAX_R), - p[1] / (u8::MAX / ColorFormat::MAX_G), - p[2] / (u8::MAX / ColorFormat::MAX_B), - ), - ) - }); - - display_target.draw_iter(pixels)?; - - let now = std::time::Instant::now(); - - flush_fn( - display_target.data(), - 0, - 0, - DISPLAY_WIDTH as _, - DISPLAY_HEIGHT as _, - ); - - let delay = std::time::Duration::from(delay); - - std::thread::sleep(std::time::Instant::now() - (now + delay)); - } - - Ok(Self { - flush_fn, - display_target, - }) - } - - pub fn new_with_png( - mut display_target: Box, - flush_fn: FlushDisplayFn, - png: &[u8], - delay_ms: u64, - ) -> anyhow::Result { - let ht = - image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); - let img = ht.decode().unwrap().to_rgb8(); - - let p = img.enumerate_pixels().map(|(x, y, p)| { - Pixel( - Point::new(x as i32, y as i32), - ColorFormat::new( - p[0] / (u8::MAX / ColorFormat::MAX_R), - p[1] / (u8::MAX / ColorFormat::MAX_G), - p[2] / (u8::MAX / ColorFormat::MAX_B), - ), - ) - }); - - p.draw(display_target.as_mut())?; - - flush_fn( - display_target.data(), - 0, - 0, - DISPLAY_WIDTH as _, - DISPLAY_HEIGHT as _, - ); - - std::thread::sleep(std::time::Duration::from_millis(delay_ms)); - - Ok(Self { - flush_fn, - display_target, - }) - } -} - -pub struct ChatUI { - state_area: (DisplayArea, bool), - asr_area: (DisplayArea, bool), - header_area: (DynamicImage<4>, bool), - content_area: (DisplayArea, bool), - - pub flush_fn: FlushDisplayFn, - pub display_target: Box, -} - -impl ChatUI { - pub fn new( - state_area: DisplayArea, - asr_area: DisplayArea, - header_area: DynamicImage<4>, - content_area: DisplayArea, - display_target: Box, - flush_fn: FlushDisplayFn, - ) -> Self { - Self { - state_area: (state_area, true), - asr_area: (asr_area, true), - header_area: (header_area, true), - content_area: (content_area, true), - flush_fn, - display_target, - } - } - - pub fn set_state(&mut self, state: String) { - self.state_area.0.text = state; - self.state_area.1 = true; - } - - pub fn set_asr(&mut self, asr: String) { - self.asr_area.0.text = asr; - self.asr_area.1 = true; - } - - pub fn set_text(&mut self, content: String) { - self.content_area.0.text = content; - self.content_area.1 = true; - } - - pub fn set_header(&mut self, index: usize) { - self.header_area.0.set_index(index); - self.header_area.1 = true; - } - - pub fn flush(&mut self) -> anyhow::Result<()> { - if self.state_area.1 { - (self.state_area.0.render_fn)(&self.state_area.0, self.display_target.as_mut())?; - self.state_area.1 = false; - } - - if self.asr_area.1 { - (self.asr_area.0.render_fn)(&self.asr_area.0, self.display_target.as_mut())?; - self.asr_area.1 = false; - } - - if self.content_area.1 { - (self.content_area.0.render_fn)(&self.content_area.0, self.display_target.as_mut())?; - self.content_area.1 = false; - } - - if self.header_area.1 { - self.header_area.0.render(self.display_target.as_mut())?; - self.header_area.1 = false; - } - - (self.flush_fn)( - self.display_target.data(), - 0, - 0, - DISPLAY_WIDTH as _, - DISPLAY_HEIGHT as _, - ); - - Ok(()) - } -} - -pub fn new_chat_ui(start: StartUI) -> anyhow::Result { - let StartUI { - flush_fn, - display_target, - } = start; - let bounding_box = display_target.bounding_box(); - - let state_area_box = Rectangle::new( - bounding_box.top_left + Point::new(96, 0), - Size::new(bounding_box.size.width - 96, 32), - ); - - let state_area = DisplayArea::new_text_area( - state_area_box, - get_background_pixels( - display_target.as_ref(), - state_area_box, - PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_DARK_BLUE) - .stroke_width(1) - .fill_color(ColorFormat::CSS_DARK_BLUE) - .build(), - 0.5, - ), - String::new(), - |area, display| { - Text::with_alignment( - &area.text, - area.area.center(), - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, - ColorFormat::CSS_LIGHT_CYAN, - ), - Alignment::Center, - ) - .draw(display)?; - Ok(()) - }, - ); - - let asr_area_box = Rectangle::new( - bounding_box.top_left + Point::new(96, 32), - Size::new(bounding_box.size.width - 96, 64), - ); - - let asr_area = DisplayArea::new_text_area( - asr_area_box, - get_background_pixels( - display_target.as_ref(), - asr_area_box, - PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_DARK_CYAN) - .stroke_width(1) - .fill_color(ColorFormat::CSS_DARK_CYAN) - .build(), - 0.15, - ), - String::new(), - |area, display| { - Text::with_alignment( - &area.text, - area.area.center(), - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, - ColorFormat::CSS_WHEAT, - ), - Alignment::Center, - ) - .draw(display)?; - Ok(()) - }, - ); - - let content_height = bounding_box.size.height - 32 - 64; - let content_area_box = Rectangle::new( - bounding_box.top_left + Point::new(0, 32 + 64), - Size::new(bounding_box.size.width, content_height), - ); - - let content_area = DisplayArea::new_text_area( - content_area_box, - get_background_pixels( - display_target.as_ref(), - content_area_box, - PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_BLACK) - .stroke_width(5) - .fill_color(ColorFormat::CSS_BLACK) - .build(), - 0.25, - ), - String::new(), - |area, display| { - let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() - .height_mode(embedded_text::style::HeightMode::FitToText) - .alignment(embedded_text::alignment::HorizontalAlignment::Center) - .line_height(embedded_graphics::text::LineHeight::Percent(120)) - .paragraph_spacing(16) - .build(); - let text_box = TextBox::with_textbox_style( - &area.text, - area.area, - MyTextStyle( - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy16_t_gb2312, - ColorFormat::CSS_WHEAT, - ), - 3, - ), - textbox_style, - ); - text_box.draw(display)?; - Ok(()) - }, - ); - - let header_area_box = Rectangle::new(bounding_box.top_left, Size::new(96, 96)); - let header_area = DynamicImage::new_from_gif(header_area_box, AVATAR_GIF)?; - - Ok(ChatUI::new( - state_area, - asr_area, - header_area, - content_area, - display_target, - flush_fn, - )) -} - -pub struct ConfiguresUI { - qr_area: ImageArea, - info_area: DisplayArea, - - pub flush_fn: FlushDisplayFn, - pub display_target: Box, -} - -impl ConfiguresUI { - pub fn new( - qr_area: ImageArea, - info_area: DisplayArea, - display_target: Box, - flush_fn: FlushDisplayFn, - ) -> Self { - Self { - qr_area, - info_area, - flush_fn, - display_target, - } - } - - pub fn set_info(&mut self, info: String) { - self.info_area.text = info; - } - - pub fn flush(&mut self) -> anyhow::Result<()> { - (self.info_area.render_fn)(&self.info_area, self.display_target.as_mut())?; - (self.qr_area.render_fn)(&self.qr_area, self.display_target.as_mut())?; - - (self.flush_fn)( - self.display_target.data(), - 0, - 0, - DISPLAY_WIDTH as _, - DISPLAY_HEIGHT as _, - ); - - Ok(()) - } -} - -pub fn new_config_ui(start: StartUI, qr_content: &str) -> anyhow::Result { - let StartUI { - flush_fn, - display_target, - } = start; - let bounding_box = display_target.bounding_box(); - - let height = bounding_box.size.height; - - let qr_area_box = Rectangle::new( - bounding_box.top_left + Point::new(0, height as i32 / 3), - Size::new(bounding_box.size.width, 2 * height / 3), - ); - let qr_area = ImageArea::new_from_qr_code(qr_area_box, qr_content)?; - - let info_area = DisplayArea::new_text_area( - bounding_box, - get_background_pixels( - display_target.as_ref(), - bounding_box, - PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_DARK_BLUE) - .stroke_width(1) - .fill_color(ColorFormat::CSS_DARK_BLUE) - .build(), - 0.25, - ), - String::new(), - |area, display| { - Text::with_alignment( - &area.text, - area.area.top_left + Point::new(area.area.size.width as i32 / 2, 32), - U8g2TextStyle::new( - u8g2_fonts::fonts::u8g2_font_wqy12_t_gb2312a, - ColorFormat::CSS_WHEAT, - ), - Alignment::Center, - ) - .draw(display)?; - Ok(()) - }, - ); - - Ok(ConfiguresUI::new( - qr_area, - info_area, - display_target, - flush_fn, - )) -} From fecff65f6836f06d6b1f7b80cdc38bff91673983 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 12 Jan 2026 03:24:55 +0800 Subject: [PATCH 07/22] update default avatar --- assets/avatar.gif | Bin 0 -> 18975 bytes src/audio.rs | 1 + src/main.rs | 2 +- src/ui.rs | 9 +++++++-- 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100755 assets/avatar.gif diff --git a/assets/avatar.gif b/assets/avatar.gif new file mode 100755 index 0000000000000000000000000000000000000000..14b20436674de10b196b977bab3f568ed21d6e15 GIT binary patch literal 18975 zcmd?QcTiL9*Y~?qNTDY5ZbEM&C?X+q=4Pc-Whc;bk_&RPYpJPqdFc&>S&hZd%1ZKDN~tf)sZC{h9hLd56$PzTg&oyJ zwY62<;gs>DoNtNLKba-W;{5m2lBx0vdQDMhO&x;V6+z1m(*UGH9FAE&v8L+jtG9oVby-)$Wj8ti@3PoEs@nq>9A zn;4#(e6`)p+8%y8KQq2F_2D~vVs8G!?@v=p3-1=be*W?E+uE-$e-?kO|NXwX`fY3V z`_}4@&6S^(-? zEbneE?)*Fd~8GtZSL%B@9uHm-Pzs!_r>At?(O}% z;p}nt_V)hkhWc+bWB2&ZncJT?HMTK7sI3D5alblt6ABgmcgxYXw6?%F-|_bKcfEVp z)dh3kGr$Am5fI>e*YLmr*9Y#NZra?V2k!U=czSz2a0Rsfz3_kkSO8)Vh(yW;{dhya zhTt1(sXvYHibTi=W(T3L=8>X`hIK)@&So+D6c4rr#iaHp@!95SmHIk$hN4cE&GsxS zTV?V{eX&n}?P{H*^YHhM_31pEW2s%nnKFBI+Z5F+7Orako^~mQVRn_#cItN3HeE%r z-xoDszfi3{cs%OG=aHw%Z?*Sl>ZHA+=v9sk^N4#o)ZLKajeU5rYO3?ZozK(tH2hdL zVs%8W<*$#+3)|N>e?K@(>vxLfD?hF^?b>;*K~kEd-g4({?{R@w0{oL@m#T%V@B6fE zxtCu&d#&7TW-8EmxaZ#Ab3V1$xzDWaD%TUO4NEloXW@m|wQpSug}Lwd-pk2q-JI!< zfy6($9ui#gA??T!4f#jsS7s;n#2&vd>ALb!3YOr5y9af=*ScAMBe+xD6M0Zj zb%tCWq1Q3Uxv*Y=0D7?M0!ISh=ulZIHI!Gj0Lf53Vo0`kAxpVfLBM!SS3V^Z56TBu z9A$;=n5#;Wx~}?ng1{(T5#PZkd|9c6Rext({WtPOQ&s!DXfGt?#CI?(*$dAkLPub7 z?%8AW(0r&BPLsbO4^M=uH!TuD{HT@;d_dLVGa9j>>`p-mwM87Pz@#jcNB1BOdTk@B zutLX+=%e4EmlIH5lz4>q8gh7$AX3TK$Mdb*@QH zn$DNB1eY7iHc)VgZ}{}BAF&W9&l#O+eobE=KmdaEhYLuX;pxeB2LK{W@2jx~Hv5Q9 zC>kf}V<03h$tDP14Xdi8OJ~*05xr<2kUZT2i!kP+k9SJiQhJ0d*a?~xC6&HO$hlMiad|^YEzOst z7+VeNFaJC}$%3d0unfW&XG}kdhrTLYPDQl>po>7ivWqd_(=(w`!(T%^9_W%3@C(A3 z>U*8;k84T4h6p@GIz0MJMtp-y7{BBN1m@HMy|It7aG!LWqgEie0gK2#f3)|0`(MQ6 zZUug}I^R~T#$Khi`L6?7bAR^ra2lLs#a1yt5cxtg3kU7w9Ft()9P<|(7o^1Hy=!*GIbG8^*W8t`c7It7N-W+ccQUyc*@=?`=Oq1 z$@%M>WhMwb2p)6mQx0onzTrIIH3P72&7`r7*D7KZ>WQpHKtDd1X!<9;a)j;6Bgym@ zY6++8I}J*`W~C1Gy?6U9l{Fe@@%mYn`ETLWTVTt0T^{!URkfN)>mTBcg*2`B9(`o^7M$Z^qncjN$797wp8+$u9 zHYv-Va=$S>oZcYc1XDx2=%mjs9(zdxYdx0iw!paEJ(}H9{vf4$%UfNdS&gm6#GE~+ zCW=?ODFzdQh{S4u_^WK3pEfj>8%9*?Tm!>Mkc}qo9DWuj_lR_d34}r8j9^E zO$YQ9Yz#yW^B125V8`(XvaTKbDw{v`Y;j0eX^HNJzc|@u_`6J;0EGSVCR}<45-{W2 zi2P8-F2kq(l?)5^uxb}+KKjN~F_pJp=H&D^&BtB#NdbINT}?g3`>biZ1W_r#Y2^Bw zr3}S)Uih45zQ{#0OpDjm_)NnQkBApM^oz;LOCGP^e7{>bGW%2Dm{@ahU|lJr4gOD1 z9{NGhVKs)Alli_zI6np_lmJ zV`QEBH6Q=6DJ|L`zlSUHuV4n`dK{T}C=Z>AAvNvBd@6pNIAy#?xze%d%y-1Q2)l8S zeyaMI7i!^fdfXdb@;ckkBIN2+R72Hi>?7wp4_8A)@Z~Ex5S3yl!UW&j>fx~dUsjP< z_<}J(3JoWG&n_}%{3E;@cPl?Btq^RV&;vE9;-tbb3Lw~qanJ6vaHqyA3(Vs$B!zQV zEC05kb0$smK3tx6`*9=(c&3kxzevAWRfsj4)PGSa@A)ZoZrOe0p~T|i{ZNUWKdDUU zzJt%dYNtP*#6Mka(X<9&lHYV7-(zv@mZ3L9Ko3(srj9duq5d-ktB-%Ryws$F>@w zPXK?Y>XQ@qiBNZ+92^9O1MA@*Kj?%+E?h^F!On@OWA*MRVNDr)fOvyb5&Vu84R!dC zvJ&fY-@4zvg~!Va$me*3AlpHx)pVmZ1gWP^2%Q-zhDbhk2n2yjFFWiFnv+ z0pg`0!E;w~6;$|q5vgoHF=p5?1K%XPk59ekGX)h357I%M+hOn}!gi~-Di6$RHKZYF}92wuw* z5N%&`tWGqBe)Kd<ldC`zFF(0bC#nR=|KU_WuN^L2wEnJ-{Ap1GK8CXaP~K|$S>v{! z97xL^B=BNPucdDa9w{0X_V>QeNrk68Y>F`5dvO(_jsd^_8dqcBooFs9fkneg!0J@6 zCL3(D2w@f8y~ctVk5O=Y{_q@~2#7l}6F@v>!5{k~GTFgj*0cq_QDkiH1+Q8g1G>fj z#-cXjM)%O^he5@WUIPf9d-sFwmcyA2I#GBe`Z6*HK)CzDe0*W|Xh~3aWDY7AUm6AW z1&%L5|FOAbC~>yPF*!qu3A&U!jvrI zWk8M&*4v}2NEq{_)3IO{AiM~iQ~-p4YoISMH(I8rHO$@@^j=Csj)L$B4iPIQ=I9`g zBdOsm(ED$I1SZ=am+eAHb+_ShHUsl50%)r7jsaTPSU+^`iU8HzE)0Ae_w*7i)g2FW zV^clVc@F!6<#3Qtz+bHYzRTr=I2(j?Nl1Nfp1SLXNV~72Yy1^iia|*09P1r;-m>L)(zE>P zsd5iYh#dEG^PkB1!pU?rc+~#^74$1OMv4MHiGtn<;Cc3-Dx4Q#9!&gmBw=9<{)B=s zIQ2yCN2=st{PSSgU7PZ;vd4M6AtzCaBCH%f48j77&K3cw?)k$1go4)Yo?H)uE48GK z8Nzd=o+$D?5seB-K_x^R*1nuZJpTq$JyvsX5t=Sh;>5#mLHy9u%=2UE};U!{%k`of$+V6;9NEeNR(KEAdRI zseE9?mz@N{;h;{7kRUodaS(2zo=gCOoSVYV48~_4%gl_aF`7(r4nh>1PCn-8bDst> zBf!r=Ku3ARW){vLPs};)3(kHGN*aXklp+l>o6lS!W^F(2EDv*WXmz(raLK51Lzb@I zjB2UB61yIBWLf)UA|OqLCuNoN^R-Ggg<1b-X6%(Ww>Y+bDsRo5K{(XdqDv{$aEv83z?NxbM&Tf_^T2(;EQ^NJ zE)qX|Nx^@V?+N`V90xLjQ=^SC<96EC2L~35dj>Y2|0?DwvId2-m90nnk-{$rM?2H$ zU}_>DNrB514-6bjV4h9@Kj@3g?s7IjGPi5ubPy-;kA>(e?74nT1+cV*MKRxAZ%5f6-8nJ)y(`$qE9~i$(T**q!BQ5zf@SYZe%Je*2xa+q+N4-~DJCoEBhZ5s+y*A6Bk?5UE9a=LKd9 zus+4T|8@NRfrxiGG$bTPG=qRlr^zlqLXz%tyYuhhy2)U!jRuATARZWm zpC5^afMj{$65_)8LTD^TR!d#^$e{yvV$$xS(%usC9+I*?GFX$7#{vyb{&U9iio<#L zo7eojJ%R&$AB6;`|Kku7boYg;Uj!jIG5qmha0KZ;7ISu7`16G5!qoV}^n~*4w5sRn z`FYQ3xVCaZHm&G+JvU0I_2q?4C3(%|c`fBdFRKb)R+Y8ZRDOs^T};SbOUzqI$=^sV zTF)%qeb(HUpHF{LPOq=*YN+jQuIX*9W41N*ceM0(v`x3S%~v;kY3>;6d^z0R_Nu$% zRZqvO-VRPagHzbG^Kxjon?BOr`KqUj)kkObGFW|`qkTQE`x&Fm&awWk@qw=K!LBi8 z@AyF9o8j(>LFSub<`ipSdVKi98`g)Zu^&AHTZ~upAEviQ-~IYLv$XKx^OsLQetuv6 zF~7X{eQ9}Vb#-ZNeQoz&8+V=S((dlA?C!2{IO{vRn;g#O-hT{P&ffMeX9vDV07An^ z;SrJKsOXs3xcH}(gv6xel+?8JjLfX;XF1QQxq0~og+;|BrDf$6mBl($HMK87CF%gx z*oMYjmDWO0ieRc*s1h>PqE4172D}VsY(z{!&ylvZUcCzParkkX{U?mVy)Qtb|Q)tlPocce`rdh{i;e^;^N!&}p}Mg^?J9Qf_3t7B3t# zSs;{jhSy*Bu!~|rq(!%a^#sp^xh7fLYph^^supdZT6S}d5+h;02P@sLO}-aPq|O>CE7z5SeqzO9qVQ)96L`;R?*7)7yv;*Qz*3PicEW;X`#u+#Z32~{E_`%1Kr09WaobywIw1?DxGKv%=#kYEuj=K=+n@UbRh6Rfa3VM#oWCU(PDz>2Y$c1 zm~&INS#{tf6`ej65G$J2i}emcTM!~B$~l*RfGBAfpUKvx2R!v@Lc&=qu{R2%68S7B3OI~By#LpiYxnjem%|l337`nSPso(CJ^xHX8d(7FwHi_M zA?p@kH&Noz(c0G);J}Rnw$ORJ(~<*!E6a zCPaP(Wera>2!E<*L?{5eg)Yv8OZI_%f9^Z4gTleQF@uqES_Mm5*neE??_m4l*>q7t zBGv>z8leD)DrF7^c<$UXYYwA=%nJ#;{_uU?HYma#h+h(g1M%T7ASfRc;|~B()~nVE zkHgXm0ip+&Iy6ca=@9wT_`iKZ4tNMZi0u1Qjzq=wA5SpJ`>u2VPO*?Za>V;CRBRD# z_UZ{W1X&oZX2Z-&{v-n+T*tMHp)P6r8KPoP9dK9KhK#F9_6}`@q49(V=zRx;rQ>(h3And5Lj^B>7E%7aA@dblFKQp?l{@)YPiAV@8-XT zK`1)%(WOvxSg7O;B>U;&k~tvzl@j)6uA7nYekL@3warD=MA!jLF8}$pc zt_`dx`_T8qpzJkyKhQb3>RTZ?T_6oQL~r(=Cr5D*VR;rI@G|(KGHX8DuDrp9iC<*{AR7RNUpi)y?1h4f zhCf$f=mTfYL;|Sfx}lV4{U-3OFoG$c<*0x$N=OW1gm1!Ok6v;a0M$8}br)GtUV7XT z-lPLObruM3I@{QgWU2ffVQ!{0iqaIlxDRg4G?8AWkk1q-Nc1x;43`rq<7SxuRBHau zIVkdfCLnGS0s{a9fIzsFO$ZnQ6-EonNQ-NWizzE%bagb3A30>AtYUo9@SM5Hd7IPM zuUzzW#oxW-`5@4bn{=Xs?uI=Hjw3yK85AByA|}R0WF^LAXQdXV#Fk_v6z1ntQPXSk zvR)MCG#BSol@-<(QyWWjo6GW>%JW}VllR?=(A z8Fl3hZkB1R>TRrM)X^9XwVlnieJ!;;&GmgR8~fVohT7_w?M;In%@YmvE7ZEBlEzOh z9RrN!VaCgm?w6dB-rY(jr+Rp|wvSUkz^Q%3seiTB%;Yo;ab6DXwvTY?UUM48IZdp+ z78a-JHK%oy)6U{_jB;8gI8ARktrMK~aZU$!p5(MnbK2jH^w3B97_a;3+$1$I-1WM@ z_w7i}#8B^>;r=(T`X@)2ldrfj_7;iaU zZ#lg)oX&UKLsRUxqd!y^y|5)w;yNUe18A-6MN>{{N&e9?|(1MZclyuvhexO z_m4k+{a9T1xc2MY;^L2urLW7&i>s?E9M0d3jTO!wH&%Cc);XLt&K@_o<;+cP{qo-| zxwpajH%o5qa<({o+}Qq4;c|z=+1cala`yhMU+(R3{=IYl`wwp25rmMnqEQ)P2oa5| zOj>SNBuepAwpC4DPmJUtuUTeIe&17!X|$|$Z9#vM+WE4p{k4VGg4)glu}7U$O!)x& zCSTA^#WV8YCp$I?3>8A=F`%35l&>C(kn-E1`GNc5WZ zT5Qm|BH}A_qrp}b|L&&+U}u!x!EOZ=q5;2c1{lyFhyYdB&9zt+Oqd+7!v-Z&p}={X z4Tx=U^CDmhgV2S|yXbPqX?fWw=jBfW;KO>1p>RG6K?{9nMkmi8POuCfO~BMbgt5#g zD=gb_B3-@ymf`~^w0ZK-%VyG?of+nw4A}eUUXX2)0wi36m3%bxs+CZ)In8pRc1l6e zY_#j^9|3mlY`4RsR2H}!sb_pvRMZM}3H)mjChexg$U*J7zR3z41vw?4HorB#^ROK! z5pl;0A{-?PNxVOz^lZT;@BLm=3WBF!&H@8JK=8 z01(Zk<4*7rNm6%8F2niNN~k8OcP0toShbMN2vm3u4J;G-W;8PMHZRj$VNX zyCsKp!G()Bsg-{40h`0GnAs$tdWwqPd~p&n7u7Wr`uJrIUK@%4BV=@3&On48tq@6k znpf4K>DDDSYWyA!**<+K^-4Vw3vP{Qc&JEWF=5sFzSt%sGxn+emTe))gu`Shi6qQx6_4ipM6c&t~lx{#Ok#d1pecw2NJR$@AV9blFiFc zz4K(IuiNtNyKzAegkw3cW^%imfRuaG%0h}fA9>&7Pgv`Z&OeUWAjC%T&^xhuGQA3s z0q^~r>b7nDC3v-r{vbrX^p%lE@CEi+XjLqj0kyqMt4J(+5)`)3D&L%TYJx<@7{*)-wh?n zY6rCnbe_8Dmz4Ez?}1H3vsesV-xpHrmIjrZ#8~>hkgV{MFyo2PJe%72kE6D8LKTYu zCp&ZtYTh%KYNrL(-OZHRvZ=9>w7vEGmTl(GH0|lp1EjUnuVuC{a5G;eh!M_YCe}jt za=-7*lOawmpL$JB;Z0nz1yE&CW^ZTD_q%m`bF)oFDp>w*h@=zUWxS4|{9Y*L9!_b! z?rlweb3%A#kU1ZpTBF0e11S~uRF&7trgB&(MT1hD+{Fe)Gcq(~x;(wf(5>3}!kNTk zfS^{_%j|l$eAhDHuSb}r%BVX|-3Uo#gjODvAumKC3}fngma6cij7 ztAQBOLA8zRj&>JK2g=@u33rzqzet9wrD_|u<}&piK(S`H1b8eP>S1w+7aFN;`sK_X z)@e3Xq71twGxE=qBuZ+Es znn}DwfCa>=9R#7tz^JN@)a&^lU)2YvN-V^*3vY~4z6u-?a-|wS-!^h%CWgbAM@@w7 zBTt>&@gIO#A65nEQFg4UUjA>mm7DjPMyHtmMLC6`;8Wn> zZbc>zW*H=|U}DgX4MbEKHi6DmtUzv~!2<&P1z=0HYpA%1NK?%G2U|6U3DXN@X*A!< zzD|avFOnim87O~)it-0|upsFOmTF`L)nOCmf0hV7`Uw`b&_>-6SDHxLQ~4J0@XWVF zx1(5rCnjQf%5Xg@7DlSFx`Yr0!8AONC_XW{y=G6jv|=!F_e~{^q`^*+=>|#LfJM@E z{%SZdcrTDmVy0z?$g5O{${hr>9ZU$}&2=V!4V}Ip#-qkxfGCqjJmp*VZ+Z2o@4%%_ zTn>S!;?*o~d+r^AWyrM&f%6fOHrvxRHauzwIUwYN?;!dh0U`@k39>#6V2n{2mOs9G zSwa`iUAdWQ(ruh7oUGJ|S?)3AEL}7R91}Bh2JATiL=tDBA^=$GUB&zZv`Mz0V7d-E zLT+ku0N5xY{wcm0K#`Py>OfsGC=~Jy{ESDF5(}Y%n)@E@{q7S2LTXb@4I$Kt57N4e*x28ol2E{lkG+<`Q^!1uan8-J*k4Q!x?XEZNRtRx5=alJc* z&ubCjV}a5M0S;@(yEaI3bG)2|ovFtoCj#*%8xg5v10KCTx9Y|Z0x_2q@8FRc6eNHT zzjTRM7KrK+^9)z>OcW=YUBExZKib~wLmY8KG83=t+M;bnK_)mY0Ef(^BB|`iu3ls= z>UM96bL}N^jDvlitFxnnbJ$_{dw29E6d1T5f~6vJC@}{-P^IY+R58y|$yf%{$sod) zY!JMD51mkhY>{;DnTR$u$9HZx#Yj39r90o#ch56N9kWCqRYm1|MHbToWQ)SPKRcCL zxt2J%MoHdguf;gP&VUodnu2(fj>JuD#Fl%IH$BKO#>3wm{?GmVL~4LzY-H^vRNF>O zKoNOIlA!eY>E2{~rlm7UB1R&Cyxa8XOM1Zfpjb>sfNCVUneJ78MEF;U*TultF%NR9 zyDL>WQ6s}QumnwVe;_at?8<0JD%iNBxtV16CElUINtXsF8&gcaT@G}^PX$GsDNVYY z>Z@ri0meFa^CTY1Na_kFpZb;nxAl5GFZ?*j*+?ZK+072ypK{Ps6gKCPtHYa*I%^o= z>haCr)zijx%Bfc$b<-GS)i1oJkGkdrwzPp$7gK#R((?hJ7xpt^E6s+#*6%{n!@Mxh1m_oxs9dN#cCQE3H`kL-0 zT2IT1zSg?__6BBK^FRkTS|?jt*7BRTi(5C!+jh&_x2xJe)wB#ST828Chr61G=q)4N zEw6fB4l!DXx>`p%+eW(DhPpb2yW2;4+F8AAtnPMJPseCq$26m3w~)aprSFw6xWw*m zC1bamzEjb;SJAaw)x|04;gt1pDw)5VI=^-Ftk-t#axvZ-F1_2$q4jb81$evnUi9xY zGXJ%7d+DscuGf8BC%1EapmSn~tB`vp272EP_fCxrOpkJ5-jT7_qcc-){`QZ}y=8rx z8TC=f~;aU*2nzqi+aZ*BZsT3%dV zS=?M(+*)7S++5n)Slr%P-q~E<-ub(=xw5s*#d6nnxv{&xv%?j!8(anZ-(RBsf4zeL zEs5*e&o6sbTT2N{023w5^Z^^IFLW^;WiwR9>tZ?zqS>wFVE>VysFx!x79>K5B^}nh zNx^o7(~IrW%4ExcFor0Yn98kfd=vtN+IeF^R31XEsdeVHueh%GMsc;Z3ZTncS_Ld8 z`V6huJ5jWMzX58ppte`V6ChjHVzmiUR|`X#gUgHvrBfP5M$?bR-BJci`BF5RR9}TZ z6j-y;0uh8&%CfYY9d1Vb&@WN^3|yLKyFRDP`vFZzS+#qD{SMFX;90 z=37KQ#F%&tt22CEavnm%W&VN_iE*eJZ5AAWiT@;AzTdZGgNAD9q2a_zWf1Pc4Ranx zIMm#icvnpl8-58IjO7883GAy^z^l0Ks2!eB5ClMcb(HmE8N*{UjA6D=odR_Jt^R(i zNXf4$lcx51{GuX}Kr28H4G{QbB6b0lY=QzpP~A~#j_#chVFco1n(lyuqB-hQiDFnk z(kxTx1d9zp+n88W`0UJ(0Pp=1m&2a~fpH{J0?do60?$QAr5pY>DDp-bvpQvN<^`t< z@Y%DwP^u=);S$$xg8)bMZk--cT`EB}$7V~FmF(wJW1ekcwD%QspS&#j#avwrTNKRP zMBqeZ)G(&ml@HLayvzZe@BBsYt!HrO{l9-Nzc3zUFWft!L8%t~Y#%CeBI1X$up!rf z1#I3(sTKn?!fjr?@hJ$e2Z&mhY;2F+U+;Hn$yD&D682*#--uP8jFm;e2gFJ8wt}2B z5KF2dc<~lW1Y6B%*PdvVHsdW?`^JN2!H6|AneEbQ!biWNJQ)WKoxm1O7T@zlzMaa= z?a6Zcsm-UA-r9r0Jn{0E|Ha$giBc6Ayp!!cUURGI4u~ylu7mTUJ-tsz_)G2no@FU- z0Sue)nFTnSw*iuKJT3dvhXdr*8l?lp@Uz&eSV`+W_JzEI(@QYn-HWZ?=A0ke>a+IS z8?R4O!2~}sw->nXqGv4+j?@Il%&g<)+iksjy8XSjCQlst9aOv@^P^MT`4ajF%feoF zf5$&*RrR+nxECCj^N#gt{8hAmrcq*l_b&Kdz&b%oApO!@=*t7BC);&dH(HMYKQE6j zGUR=Hwl<1iQH)T*i@L$HcCx?CQSJ*5H|jnvj2tMCo!jh{={;xLQr>guT~F);Bq^9E z6k?Dwsk7dWofLfdY+!ar-l0XGD=81Li4r^O9ZSb;ycm^wM0h2o>L z!6fZ`>4}#3x1F;@RG_4c3>IL+J%4EmW%A9G@A<1SiXWNruFB*g&Wm@W?DzspM648r zH<~VfK6kzZq5zckh{n#ULcrY$$Xn^nF{5#gN6&QQ5DD~Rr&pd)r)7(K1CIeDxS}^U zr-)qAGxLitdjBWUeY|6EL&wp_0VFx#l~qNo!2;yr&{gs6uP2hPJtaX(NjO=*T2=ie zqNCP(@u!XR=+ybS=A*du-!)Hld2beP-B*8itF&u)?{vXjqE86mZXlGmhk&XqdV{AF|B^FIj$J5FyLTubdI1R(P%wz3 zaaBOClwtvZiB^Xa6QlcEgYteT2z%^<_cYi5_&OL-iVQl4hpu7|C5c`@V7FMs+8kO+ zoUGMFOeoRN+lwG<#NM|7BwrWr2DfTiosk1)e4RyQo;_n{zS2{DbU8ujPXN>@O zXseD=?G>jrth%TKg3hY#B}{e zQKzf?ZZFO4Vi!b*iH^=-g~zUi#$xzyl6|G8@fGu69Q5;NuM4PU zY-<~~6D`h!*&Mt%OmN`$#D$tl)>D#Ub-Y#{ol&OzfY7*m7g9YuUT7KlOt=7yfGtVm zvNMR|mY8Grn4T{jD}`Q3T0v?+Zal)#c3SgjsC776n@zSub)(`pplZ-gB#G=KV4A9v zETBPwopjlYuQoBse6EGkCCytSq zP-*o#+xLr9mILN~ggS7-Hd;&%K*T$kot4Lden~o^!|6DP_?+w6-W5STi@#BC=@ELg z(~5_6Pm8IYbq~b_zsTG%Q`1`_f^6=BM5u(?Z@A_5Cn?7Q?}2 z&tByGzGJQrm9tNIrmtonx`K4Nv@^f-W+NB0xb!sgD&{_AHEPN?@;H9^oWzn)R;&)f z1ao)PqkXN*S!Oxw4jtm~0pQVlKUH^1p%SCY#D?a4Ic5}sLi|L$22cnyR4B>^poa>L zlEXa$dEkq50bk#+_B(jM0oI@d10%@2H_aaf0-B(%1`yjO3ssDnCktf1r|j#QN2@eh9ANhr!A@V5Z#T2!^^ z^Bvj;MEuJWlfx|?tME}X_SD|i-~1WOxV@b<`NQMiuXh&4iZ8CgfqScHrDex=68@`~ zp1>GSqAcVToy5mo?pHMuK!_)ms#l%@Q1yvmh0zN}?hmise4h_0qCf|1s1H`>%Bhe zpJRfuB7#Nu=kOTSw0(jIENob1re;`dj+!7xE26F`v6wC((A$j4VFvvcBq_D7{tgNJl zq^O#z!XaHPBg4byvin>P95ypKe(sd{#q;N!t~=cJbiVKJ9sKY<@uYR^#cS1Pu7*9h zTXp;X+Y7g6uKIs)4hkbYcuIQoAviWHoER4q!R1Yolb>>HfcZ($MQQP+nOy25sUjyW zFF&W6nnBC^Pd%_8yRImwyrQ_FgxkrO-&|SPQeE6qQ{G-%Iuo7o{V8=Tp?EvBd?&M- z@uHGmU(wZ2#b~POZLaBhSFY4xV%hw8<7GAUrGMc%{hg)Wd1&<H?SuCq%Xo4W+C*p}e9Rh{{C*hH$-C$x6hGL;(dn7u3^Wd4P;O zwD>dFtUIV$79_%aryyszZ7Q%TLMMo0{b&&piXm}*6FYi)P!6M@En`cZi zM8hETqGxf_WGC;@GD<#1``|%iUQ>;OJ$t_S0vSsG{7@bXwG7`#wc1}aPBT$4SLn=F z_$dJHlLjiVeb|p`i3*CI5A3oPIDY=4*gL{tCq+%PHLvV|ZK9Oo(SlSfBp?1flSd`F zmxP($^<VyI$@#gesT%xyoW3||wg7vtST8RT&RG!atb^nuUn_-s$B zT&NL_0fFjJ888$IM}UOu`{JTPS3G$SYmq5YHXi)BjAr=|b~Rk)HWwZAQL~v-`tZ!< zW9%)9(9Zoq8{0(q?x`Kdp5PNvV?ZjBzQjb@Sa>G$C^+;ZG9t5$aeUWSj)8@auh1bv ziW?8jp?cT-Pcx%z*r8@?1A#~$RFIB|&{YdFW@N1{6 z{FPeZQ+a{+z`01E+b_DruLxWo^X0}E=b@orj+N}n>uU0N*v71!J?U!1No=MjX(N8@ zDWr{wUUp7cg@W24hKod0EETG1#o%?MiM?u}!j626#{J)c9NgudYjSaULJ)*Y5+ZqF z!U(8{5TB&Du!ax{D=(#?s&H6W>)bx&+xxVPk00? zt_dcc+f_b3K%W?7ycy_vGt@QS-~EQ!{bsQH%|PGV;qJFX|2kFNw)3gMzNw*}>EWKq zk?yHiJ(I(IlOw%Tuli=V^9XaAOL`0s%)Vkyv-)O6`({`Jvn=N9X#ec!;OyAI3~Tt^ z*zoM=$cOQfFU-Eb-GdzZD-Poodu))?#o{ntbGlx0df#$7r*>XVvnNMC&Q2`N%zS0P z{V_ky1sXqnn%n&NY4yj)wcr2tw6FaAwX*VeeVyByzQoyD;%qN*c9ywJBZsrfWg7nr z)!;%J++Y6x=i`6Vd9!%QlcbV>lR7jNsZ5CkY-!oM;)p~--V1L>6p*7&C?_SxeU9Tb963??SF zP5~qe(S%@g!O@ggOam}*7y@u;7bbwxL(RSQd@6@IGhh(wa2rm1s;gO_zNJf$2Ae{@ z&gJU^2!LrQOR`)rfnq6(r2Rd*V&>B4Z#7?4Duoq4wr)@hLxoPg=}S1@9Vfl*tOCGX z_7<@PM%@LWAbWOJD2j&8W<5*&;orYq$AntOB<*w02n&4Q!kx^WNqq5zYtkp0Rm zVMwX+Tfb8EPO>=XsG6-%s#9d{6F?LNCh~02LHCcJW56pC%_35~F0>8^?Xf zrC$^oTgX?~=VeU7*_};(7qSmb={kD_YfM6!C1Vu4JfV!{KO3?ksVL#dL;!ZNQeR3RNsFp@s!JNOQjRX~8MCD0*D^pN z=WIY4B`4lQVJ)7GEoeTY9<((>yRAlf$<|s#OUnBpmO7((Dx08~7uvVX1dr;}Xi_9Y z%QDSsrHD>m^YNLc=tw=sa%#xNoEC5pmGHezBN0aD!-FW$hB#hn%SubxTTY6`q0=PX z&$FVW;+Bhs8VV2DH5Sr!(NabG9yxJsepJT{h0GQU3%ZUU zL7y!==X_IGK1I4u*Q4eqCIWEyK5((EaPui<@7Q?!&Dw;KXByYxq||+>`kjrx&Ia>< zCQAW_Ha9V0ujOo(7Eo6Ei@qpb9k{b5bz>$;}a8^#Vq|5QhN-sBMJq^herO5AqKpN8^RQ? zM#fEG1rg*N6@o~SrAl@$?A#&;G6ITNt%Y@D)X)SezkvXvjuNcM6cMt?i|`1O^7CCm zO1YTq0m3&Fxxnpc*(bl5K_D}Hi1YyPE&w>vAVw4j2dD#xNQQ)u1CfkXB9fICI`WwW znSns8Hl~cYNjO7f;X&Ng5cPo)A={kMNXW-IW_pB@5?N+Pgi!y!MmDmI(QL>~NYH{8 z=zw%uJV_D~5(3xZ69D%CfC&Ub0*aX6q8J5$3b52hksu%;7KmF60Z;;xmNX+NNWnx6 z6@!kl1P~h&$VE?jQi&8vrYYShmXvTshKMttiHzw$QXo--43i-iNL$th#JdUt;4cdq zB>*;&2>?`eA|Ze&8WWO5hcwcQsA}FtAn}DScmWa=*@MsC#>$Mur3{W-p+MdMfVRyV&FwL=@NsE-D7W7u=viAfv#pC=kNf%^nxI zcl}5l4sl*A6lbiHzymg{0113Tz@rE+Lfi{sDFn&70ARobR2z{$rUkJQo~;(R z5Lqu&xW1_9!~h(y;RiTi0C@Ori0f+*35(=c0}fcV4LUFYFqpzE z0GNc;(ol&?NE;)lz{3Et?STh)7$mHR@W)N`+MI7s3 u54+gMPWG~!{p=>;1VK2VcC=HZ?P_nk+bt6Jxkp6q0^zyc_kQt!002Abdcnc~ literal 0 HcmV?d00001 diff --git a/src/audio.rs b/src/audio.rs index e8555eb..bcfdf9d 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -494,6 +494,7 @@ fn audio_task_run( send_buffer.push_i16(&items); } AudioEvent::EndSpeech(sender) => { + send_buffer.push_vowel(0); send_buffer.push_back_end_speech(sender); } AudioEvent::VolSet(vol) => { diff --git a/src/main.rs b/src/main.rs index 993aa48..d692567 100644 --- a/src/main.rs +++ b/src/main.rs @@ -232,7 +232,7 @@ fn main() -> anyhow::Result<()> { unsafe { esp_idf_svc::sys::esp_restart() } } - let mut chat_ui = boards::ui::new_chat_ui::<4>(framebuffer.as_mut())?; + let mut chat_ui = boards::ui::new_chat_ui::<6>(framebuffer.as_mut())?; chat_ui.set_state("Connecting to wifi...".to_string()); chat_ui.render_to_target(framebuffer.as_mut())?; diff --git a/src/ui.rs b/src/ui.rs index b484cfd..f8d5006 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,7 +13,7 @@ pub type ColorFormat = Rgb565; pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/ht.gif"); pub const LM_PNG: &[u8] = include_bytes!("../assets/lm_320x240.png"); -pub const AVATAR_GIF: &[u8] = include_bytes!("../assets/xx.gif"); +pub const AVATAR_GIF: &[u8] = include_bytes!("../assets/avatar.gif"); // TextRenderer + CharacterStyle #[derive(Debug, Clone)] @@ -392,7 +392,12 @@ impl DynamicImage { } pub fn set_index(&mut self, index: usize) { - self.display_index = index % N; + let new_idx = index % N; + if new_idx == self.display_index { + self.display_index = 0; + } else { + self.display_index = index % N; + } } pub fn render>( From c17115a05dabb2cb54fc05f07df283ef405107a1 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 12 Jan 2026 04:01:47 +0800 Subject: [PATCH 08/22] Add 'Ready' status between 'Idle' and 'Listening' --- src/app.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0463c93..3e55c4e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -215,6 +215,10 @@ pub async fn main_work<'d, const N: usize>( framebuffer.flush()?; server.close().await?; } else { + gui.set_state("Connecting...".to_string()); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; + server.reconnect_with_retry(3).await?; let hello_notify = Arc::new(tokio::sync::Notify::new()); @@ -231,7 +235,7 @@ pub async fn main_work<'d, const N: usize>( log::info!("Hello response received"); state = State::Listening; - gui.set_state("Listening...".to_string()); + gui.set_state("Ready".to_string()); gui.render_to_target(framebuffer)?; framebuffer.flush()?; } @@ -323,6 +327,10 @@ pub async fn main_work<'d, const N: usize>( start_submit = true; server.send_client_audio_chunk_i16(audio_buffer).await?; audio_buffer = Vec::with_capacity(8192); + + gui.set_state("Listening...".to_string()); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } } Event::MicAudioChunk(data) if state == State::Speaking && allow_interrupt => { @@ -526,7 +534,7 @@ pub async fn main_work<'d, const N: usize>( Event::ServerEvent(ServerEvent::EndResponse) => { log::info!("Received request end"); state = State::Listening; - gui.set_state("Listening...".to_string()); + gui.set_state("Ready".to_string()); gui.render_to_target(framebuffer)?; framebuffer.flush()?; recv_audio_buffer.clear(); From be66f732f13ce5e3faae19af3f9a6befa069e370 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 12 Jan 2026 04:06:01 +0800 Subject: [PATCH 09/22] Render avatar on the first frame --- src/boards/atom_box.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index 14417ed..cb9611c 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -393,7 +393,7 @@ pub mod ui { content_chunks: Vec::new(), avatar: avatar, - avatar_updated: false, + avatar_updated: true, avatar_chunks: Vec::new(), } } From e18ed4e54c36c35b3eca99e2f0de6837d74588d6 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 01:44:46 +0800 Subject: [PATCH 10/22] chore: remove useless code --- src/boards/atom_box.rs | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index cb9611c..7d3d0cd 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -292,7 +292,6 @@ pub mod ui { } fn flush(&mut self) -> anyhow::Result<()> { - let now = std::time::Instant::now(); unsafe { let panel_handle = std::mem::transmute(esp_idf_svc::sys::hal_driver::panel_handle); @@ -330,13 +329,6 @@ pub mod ui { } } - log::info!( - "Display flush took {:?} for {} chunks, {} resumed", - now.elapsed(), - self.diff_indexs.len(), - self.resume_indexs.len() - ); - self.diff_indexs.clear(); self.resume_indexs.clear(); } @@ -435,14 +427,6 @@ pub mod ui { let bounding_box = target.bounding_box(); let (state_area_box, asr_area_box, content_area_box) = Self::layout(bounding_box); - log::info!( - "draw ChatUI {} {} {} {}", - self.state_text_updated, - self.asr_text_updated, - self.content_updated, - self.avatar_updated - ); - let mut start_i = 0; if self.state_text_updated { @@ -637,26 +621,6 @@ pub mod ui { } } -pub fn flush_display(color_data: &[u8], x_start: i32, y_start: i32, x_end: i32, y_end: i32) -> i32 { - // if write area size > 80, lcd will display wrong data - - debug_assert_eq!( - x_end - x_start, - DISPLAY_WIDTH as i32, - "x_end - x_start must be equal to DISPLAY_WIDTH" - ); - unsafe { - esp_idf_svc::sys::hal_driver::lcd_color_fill( - x_start as u16, - y_start as u16, - x_end as u16, - y_end as u16, - color_data.as_ptr() as _, - ); - 0 - } -} - #[macro_export] macro_rules! start_hal { ($peripherals:ident, $evt_tx:ident) => {{ From fdb7384bfd8acc757ba34c23bfa40427fa891bf6 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 01:48:18 +0800 Subject: [PATCH 11/22] fix: change button interrupt type to AnyEdge for better responsiveness --- src/audio.rs | 6 ++++++ src/main.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/audio.rs b/src/audio.rs index bcfdf9d..0c9b52d 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -131,6 +131,12 @@ impl AFE { result.vad_cache, result.vad_cache_size as usize / 2, ); + // log::info!( + // "Using vad cache len: {} {}ms", + // data_.len(), + // data_.len() * 1000 / SAMPLE_RATE as usize + // ); + // 352ms data.extend_from_slice(data_); } if data_size > 0 { diff --git a/src/main.rs b/src/main.rs index d692567..eb536bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,7 +121,7 @@ fn main() -> anyhow::Result<()> { // Configures the button let mut button = esp_idf_svc::hal::gpio::PinDriver::input(peripherals.pins.gpio0)?; button.set_pull(esp_idf_svc::hal::gpio::Pull::Up)?; - button.set_interrupt_type(esp_idf_svc::hal::gpio::InterruptType::PosEdge)?; + button.set_interrupt_type(esp_idf_svc::hal::gpio::InterruptType::AnyEdge)?; let b = tokio::runtime::Builder::new_current_thread() .enable_all() From 165905d15bb990b33923f3b41817681e4f7748d5 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 01:53:05 +0800 Subject: [PATCH 12/22] fix: update AFE configuration for improved audio performance --- src/boards/atom_box.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index 7d3d0cd..50f0b5f 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -9,7 +9,9 @@ pub const AFE_AEC_OFFSET: usize = 512; pub fn afe_config(afe_config: &mut esp_idf_svc::sys::esp_sr::afe_config_t) { afe_config.agc_init = true; afe_config.agc_mode = esp_idf_svc::sys::esp_sr::afe_agc_mode_t_AFE_AGC_MODE_WEBRTC; - afe_config.afe_linear_gain = 1.0; + afe_config.afe_linear_gain = 1.5; + afe_config.agc_target_level_dbfs = 1; + afe_config.agc_compression_gain_db = 15; afe_config.ns_init = false; } From 7998e11af5fd004d2779c95ee6e20aaccf702024 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 02:11:16 +0800 Subject: [PATCH 13/22] fix: update Bluetooth function to use device_id for advertising name --- src/bt.rs | 8 ++++---- src/main.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bt.rs b/src/bt.rs index 2d0dd26..6cc3f46 100644 --- a/src/bt.rs +++ b/src/bt.rs @@ -10,11 +10,11 @@ const BACKGROUND_GIF_ID: BleUuid = uuid128!("d1f3b2c4-5e6f-4a7b-8c9d-0e1f2a3b4c5 const RESET_ID: BleUuid = uuid128!("f0e1d2c3-b4a5-6789-0abc-def123456789"); pub fn bt( + device_id: &str, setting: Arc>, evt_tx: tokio::sync::mpsc::Sender, -) -> anyhow::Result { +) -> anyhow::Result<()> { let ble_device = esp32_nimble::BLEDevice::take(); - let ble_addr = ble_device.get_addr()?.to_string(); let ble_advertising = ble_device.get_advertising(); let server = ble_device.get_server(); @@ -168,9 +168,9 @@ pub fn bt( ble_advertising.lock().set_data( BLEAdvertisementData::new() - .name(&format!("EchoKit-{}", ble_addr)) + .name(&format!("EchoKit-{}", device_id)) .add_service_uuid(SERVICE_ID), )?; ble_advertising.lock().start()?; - Ok(ble_addr) + Ok(()) } diff --git a/src/main.rs b/src/main.rs index eb536bf..735af16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -166,12 +166,12 @@ fn main() -> anyhow::Result<()> { setting.background_gif.0.clear(); let setting = Arc::new(Mutex::new((setting, nvs))); - let ble_addr = bt::bt(setting.clone(), evt_tx).unwrap(); + bt::bt(&dev_id, setting.clone(), evt_tx).unwrap(); log_heap(); let version = env!("CARGO_PKG_VERSION"); - let mut config_ui = boards::ui::ConfiguresUI::new(framebuffer.bounding_box(), "https://echokit.dev/setup/", format!("Goto https://echokit.dev/setup/ to set up the device.\nDevice Name: EchoKit-{}\nVersion: {}", ble_addr, version)).unwrap(); + let mut config_ui = boards::ui::ConfiguresUI::new(framebuffer.bounding_box(), "https://echokit.dev/setup/", format!("Goto https://echokit.dev/setup/ to set up the device.\nDevice Name: EchoKit-{}\nVersion: {}", dev_id, version)).unwrap(); config_ui.draw(framebuffer.as_mut())?; framebuffer.flush()?; From 1f996d9758fb691539c9af099327316d1983e370 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 02:15:56 +0800 Subject: [PATCH 14/22] fix: improve background GIF loading with size validation --- src/main.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 735af16..909cf39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,10 +63,28 @@ impl Setting { .to_string(); let background_gif = if nvs.contains("background_gif")? { - let mut gif_buf = vec![0; 1024 * 1024]; - nvs.get_blob("background_gif", &mut gif_buf)? - .unwrap_or(ui::DEFAULT_BACKGROUND) - .to_vec() + let background_gif_size = nvs + .blob_len("background_gif") + .map_err(|e| log::error!("Failed to get background_gif size: {:?}", e)) + .ok() + .flatten() + .unwrap_or(1024 * 1024); + + let mut gif_buf = vec![0; background_gif_size]; + let gif_buf_ = nvs + .get_blob("background_gif", &mut gif_buf)? + .unwrap_or(ui::DEFAULT_BACKGROUND); + + if gif_buf_.len() != background_gif_size { + log::warn!( + "Background GIF size mismatch: expected {}, got {}", + background_gif_size, + gif_buf_.len() + ); + gif_buf_.to_vec() + } else { + gif_buf + } } else { ui::DEFAULT_BACKGROUND.to_vec() }; From 92c8f0506bd1ee44022b50d02db0fec906068063 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 03:54:56 +0800 Subject: [PATCH 15/22] feat: Add advanced settings for AFE and AGC parameters --- setup/index.html | 279 ++++++++++++++++++++++++++++++++++++++++- setup/index_zh.html | 273 +++++++++++++++++++++++++++++++++++++++- src/audio.rs | 8 +- src/boards/atom_box.rs | 5 +- src/boards/base.rs | 3 - src/boards/cube.rs | 3 - src/boards/cube2.rs | 3 - src/bt.rs | 105 ++++++++++++++++ src/main.rs | 42 +++++++ 9 files changed, 704 insertions(+), 17 deletions(-) diff --git a/setup/index.html b/setup/index.html index ff002cb..bc1dbf6 100644 --- a/setup/index.html +++ b/setup/index.html @@ -92,6 +92,75 @@

Background Image

+ + +
+ +
+ 🛠️ Advanced Settings +
+
+
+ +
+
+

AFE Linear Gain

+ + +
+
+ + +
+
+

AGC Target Level

+ + +
+
+ + +
+
+

AGC Compression + Gain

+ + +
+
+
+
+
@@ -125,6 +194,9 @@

⚠️ Device Reset Required

const SERVER_URL_ID = "cef520a9-bcb5-4fc6-87f7-82804eee2b20"; const BACKGROUND_IMAGE_ID = "d1f3b2c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; const RESET_ID = "f0e1d2c3-b4a5-6789-0abc-def123456789"; + const AFE_LINEAR_GAIN_ID = "a1b2c3d4-e5f6-4789-0abc-def123456789"; + const AGC_TARGET_LEVEL_ID = "b2c3d4e5-f6a7-4890-1bcd-ef2345678901"; + const AGC_COMPRESSION_GAIN_ID = "c3d4e5f6-a7b8-4901-2cde-f34567890123"; // global variables let device = null; @@ -156,6 +228,22 @@

⚠️ Device Reset Required

const toastMessage = document.getElementById('toastMessage'); const resetNotSupportedModal = document.getElementById('resetNotSupportedModal'); + // AFE related DOM elements + const afeLinearGainRange = document.getElementById('afeLinearGainRange'); + const afeLinearGainValue = document.getElementById('afeLinearGainValue'); + const saveAfeLinearGainButton = document.getElementById('saveAfeLinearGainButton'); + const afeLinearGainTitle = document.getElementById('afeLinearGainTitle'); + + const agcTargetLevelRange = document.getElementById('agcTargetLevelRange'); + const agcTargetLevelValue = document.getElementById('agcTargetLevelValue'); + const saveAgcTargetLevelButton = document.getElementById('saveAgcTargetLevelButton'); + const agcTargetLevelTitle = document.getElementById('agcTargetLevelTitle'); + + const agcCompressionGainRange = document.getElementById('agcCompressionGainRange'); + const agcCompressionGainValue = document.getElementById('agcCompressionGainValue'); + const saveAgcCompressionGainButton = document.getElementById('saveAgcCompressionGainButton'); + const agcCompressionGainTitle = document.getElementById('agcCompressionGainTitle'); + // Track modified fields const modifiedFields = { ssid: false, @@ -175,7 +263,9 @@

⚠️ Device Reset Required

// Clear field modification mark function clearFieldModification(fieldName, titleElement) { modifiedFields[fieldName] = false; - titleElement.textContent = titleElement.textContent.replace(' *', ''); + if (titleElement) { + titleElement.textContent = titleElement.textContent.replace(' *', ''); + } updateSaveButtonState(); } @@ -335,6 +425,11 @@

⚠️ Device Reset Required

backgroundImage.disabled = false; clearBgButton.disabled = false; controlPanel.classList.remove('opacity-50', 'pointer-events-none'); + + // Enable AFE controls + afeLinearGainRange.disabled = false; + agcTargetLevelRange.disabled = false; + agcCompressionGainRange.disabled = false; } // Disable all controls @@ -348,6 +443,14 @@

⚠️ Device Reset Required

writeBgButton.disabled = true; clearBgButton.disabled = true; controlPanel.classList.add('opacity-50', 'pointer-events-none'); + + // Disable AFE controls + afeLinearGainRange.disabled = true; + agcTargetLevelRange.disabled = true; + agcCompressionGainRange.disabled = true; + saveAfeLinearGainButton.disabled = true; + saveAgcTargetLevelButton.disabled = true; + saveAgcCompressionGainButton.disabled = true; } // Reads Characteristic @@ -388,6 +491,11 @@

⚠️ Device Reset Required

await readCharacteristic(PASS_ID, passInput); await readCharacteristic(SERVER_URL_ID, serverUrlInput); + // Load AFE parameters + await readAfeLinearGain(); + await readAgcTargetLevel(); + await readAgcCompressionGain(); + // Clear all modification marks clearFieldModification('ssid', ssidTitle); clearFieldModification('pass', passTitle); @@ -614,6 +722,173 @@

⚠️ Device Reset Required

showNotification('Message', 'Background image cleared'); }); + // Read AFE Linear Gain (string format f32) + async function readAfeLinearGain() { + if (!isConnected || !service) return false; + try { + const characteristic = await service.getCharacteristic(AFE_LINEAR_GAIN_ID); + const value = await characteristic.readValue(); + const decoder = new TextDecoder(); + const stringValue = decoder.decode(value); + const gain = parseFloat(stringValue); + if (!isNaN(gain)) { + afeLinearGainRange.value = Math.round(gain * 10); + afeLinearGainValue.textContent = gain.toFixed(1); + // Reset title (remove not supported label) + afeLinearGainTitle.textContent = 'AFE Linear Gain'; + saveAfeLinearGainButton.disabled = true; + } + return true; + } catch (error) { + console.error('Failed to read AFE Linear Gain:', error); + // Backward compatibility: disable this control + afeLinearGainRange.disabled = true; + saveAfeLinearGainButton.disabled = true; + afeLinearGainTitle.textContent = 'AFE Linear Gain (Not Supported)'; + return false; + } + } + + // Read AGC Target Level (i32, 4 bytes little endian) + async function readAgcTargetLevel() { + if (!isConnected || !service) return false; + try { + const characteristic = await service.getCharacteristic(AGC_TARGET_LEVEL_ID); + const value = await characteristic.readValue(); + // value is already DataView, use directly + const level = value.getInt32(0, true); + agcTargetLevelRange.value = level; + agcTargetLevelValue.textContent = level; + // Reset title (remove not supported label) + agcTargetLevelTitle.textContent = 'AGC Target Level'; + saveAgcTargetLevelButton.disabled = true; + return true; + } catch (error) { + console.error('Failed to read AGC Target Level:', error); + // Backward compatibility: disable this control + agcTargetLevelRange.disabled = true; + saveAgcTargetLevelButton.disabled = true; + agcTargetLevelTitle.textContent = 'AGC Target Level (Not Supported)'; + return false; + } + } + + // Read AGC Compression Gain (i32, 4 bytes little endian) + async function readAgcCompressionGain() { + if (!isConnected || !service) return false; + try { + const characteristic = await service.getCharacteristic(AGC_COMPRESSION_GAIN_ID); + const value = await characteristic.readValue(); + // value is already DataView, use directly + const gain = value.getInt32(0, true); + agcCompressionGainRange.value = gain; + agcCompressionGainValue.textContent = gain; + // Reset title (remove not supported label) + agcCompressionGainTitle.textContent = 'AGC Compression Gain'; + saveAgcCompressionGainButton.disabled = true; + return true; + } catch (error) { + console.error('Failed to read AGC Compression Gain:', error); + // Backward compatibility: disable this control + agcCompressionGainRange.disabled = true; + saveAgcCompressionGainButton.disabled = true; + agcCompressionGainTitle.textContent = 'AGC Compression Gain (Not Supported)'; + return false; + } + } + + // AFE Linear Gain save function + async function saveAfeLinearGain() { + if (!isConnected || !service) { + showNotification('Error', 'Device not connected', true); + return; + } + try { + const gain = parseFloat((afeLinearGainRange.value / 10).toFixed(1)); + const characteristic = await service.getCharacteristic(AFE_LINEAR_GAIN_ID); + const encoder = new TextEncoder(); + const data = encoder.encode(gain.toString()); + await characteristic.writeValue(data); + showNotification('Success', `AFE Linear Gain set to ${gain}`); + clearFieldModification('afeLinearGain', afeLinearGainTitle); + saveAfeLinearGainButton.disabled = true; + } catch (error) { + console.error('Failed to save AFE Linear Gain:', error); + showNotification('Error', 'Failed to save AFE Linear Gain: ' + error.message, true); + } + } + + // AGC Target Level save function + async function saveAgcTargetLevel() { + if (!isConnected || !service) { + showNotification('Error', 'Device not connected', true); + return; + } + try { + const level = parseInt(agcTargetLevelRange.value); + const characteristic = await service.getCharacteristic(AGC_TARGET_LEVEL_ID); + const data = new ArrayBuffer(4); + const view = new DataView(data); + view.setInt32(0, level, true); + await characteristic.writeValue(data); + showNotification('Success', `AGC Target Level set to ${level}`); + clearFieldModification('agcTargetLevel', agcTargetLevelTitle); + saveAgcTargetLevelButton.disabled = true; + } catch (error) { + console.error('Failed to save AGC Target Level:', error); + showNotification('Error', 'Failed to save AGC Target Level: ' + error.message, true); + } + } + + // AGC Compression Gain save function + async function saveAgcCompressionGain() { + if (!isConnected || !service) { + showNotification('Error', 'Device not connected', true); + return; + } + try { + const gain = parseInt(agcCompressionGainRange.value); + const characteristic = await service.getCharacteristic(AGC_COMPRESSION_GAIN_ID); + const data = new ArrayBuffer(4); + const view = new DataView(data); + view.setInt32(0, gain, true); + await characteristic.writeValue(data); + showNotification('Success', `AGC Compression Gain set to ${gain}`); + clearFieldModification('agcCompressionGain', agcCompressionGainTitle); + saveAgcCompressionGainButton.disabled = true; + } catch (error) { + console.error('Failed to save AGC Compression Gain:', error); + showNotification('Error', 'Failed to save AGC Compression Gain: ' + error.message, true); + } + } + + // AFE Linear Gain slider event + afeLinearGainRange.addEventListener('input', () => { + const gain = (afeLinearGainRange.value / 10).toFixed(1); + afeLinearGainValue.textContent = gain; + markFieldAsModified('afeLinearGain', afeLinearGainTitle); + saveAfeLinearGainButton.disabled = false; + }); + + // AGC Target Level slider event + agcTargetLevelRange.addEventListener('input', () => { + agcTargetLevelValue.textContent = agcTargetLevelRange.value; + markFieldAsModified('agcTargetLevel', agcTargetLevelTitle); + saveAgcTargetLevelButton.disabled = false; + }); + + // AGC Compression Gain slider event + agcCompressionGainRange.addEventListener('input', () => { + agcCompressionGainValue.textContent = agcCompressionGainRange.value; + markFieldAsModified('agcCompressionGain', agcCompressionGainTitle); + saveAgcCompressionGainButton.disabled = false; + }); + + // AFE save button events + saveAfeLinearGainButton.addEventListener('click', saveAfeLinearGain); + saveAgcTargetLevelButton.addEventListener('click', saveAgcTargetLevel); + saveAgcCompressionGainButton.addEventListener('click', saveAgcCompressionGain); + if (!navigator.bluetooth) { showNotification('Error', 'Your browser does not support the Web Bluetooth API. Please use Chrome or Edge', true); connectButton.disabled = true; @@ -624,4 +899,4 @@

⚠️ Device Reset Required

- + \ No newline at end of file diff --git a/setup/index_zh.html b/setup/index_zh.html index 4c35a27..8b4e199 100644 --- a/setup/index_zh.html +++ b/setup/index_zh.html @@ -89,6 +89,71 @@

背景图片设置

+ + +
+ +
+ 🛠️ 高级设置 +
+
+
+ +
+
+

AFE 线性增益

+ + +
+
+ + +
+
+

AGC 目标电平

+ + +
+
+ + +
+
+

AGC 压缩增益

+ + +
+
+
+
+
@@ -122,6 +187,9 @@

⚠️ 需要重启设备

const SERVER_URL_ID = "cef520a9-bcb5-4fc6-87f7-82804eee2b20"; const BACKGROUND_IMAGE_ID = "d1f3b2c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; const RESET_ID = "f0e1d2c3-b4a5-6789-0abc-def123456789"; + const AFE_LINEAR_GAIN_ID = "a1b2c3d4-e5f6-4789-0abc-def123456789"; + const AGC_TARGET_LEVEL_ID = "b2c3d4e5-f6a7-4890-1bcd-ef2345678901"; + const AGC_COMPRESSION_GAIN_ID = "c3d4e5f6-a7b8-4901-2cde-f34567890123"; // 全局变量 let device = null; @@ -153,6 +221,22 @@

⚠️ 需要重启设备

const toastMessage = document.getElementById('toastMessage'); const resetNotSupportedModal = document.getElementById('resetNotSupportedModal'); + // AFE 相关 DOM 元素 + const afeLinearGainRange = document.getElementById('afeLinearGainRange'); + const afeLinearGainValue = document.getElementById('afeLinearGainValue'); + const saveAfeLinearGainButton = document.getElementById('saveAfeLinearGainButton'); + const afeLinearGainTitle = document.getElementById('afeLinearGainTitle'); + + const agcTargetLevelRange = document.getElementById('agcTargetLevelRange'); + const agcTargetLevelValue = document.getElementById('agcTargetLevelValue'); + const saveAgcTargetLevelButton = document.getElementById('saveAgcTargetLevelButton'); + const agcTargetLevelTitle = document.getElementById('agcTargetLevelTitle'); + + const agcCompressionGainRange = document.getElementById('agcCompressionGainRange'); + const agcCompressionGainValue = document.getElementById('agcCompressionGainValue'); + const saveAgcCompressionGainButton = document.getElementById('saveAgcCompressionGainButton'); + const agcCompressionGainTitle = document.getElementById('agcCompressionGainTitle'); + // 跟踪哪些字段被修改 const modifiedFields = { ssid: false, @@ -172,7 +256,9 @@

⚠️ 需要重启设备

// 清除字段修改标记 function clearFieldModification(fieldName, titleElement) { modifiedFields[fieldName] = false; - titleElement.textContent = titleElement.textContent.replace(' *', ''); + if (titleElement) { + titleElement.textContent = titleElement.textContent.replace(' *', ''); + } updateSaveButtonState(); } @@ -332,6 +418,11 @@

⚠️ 需要重启设备

backgroundImage.disabled = false; clearBgButton.disabled = false; controlPanel.classList.remove('opacity-50', 'pointer-events-none'); + + // 启用 AFE 控件 + afeLinearGainRange.disabled = false; + agcTargetLevelRange.disabled = false; + agcCompressionGainRange.disabled = false; } // 禁用所有控件 @@ -345,6 +436,14 @@

⚠️ 需要重启设备

writeBgButton.disabled = true; clearBgButton.disabled = true; controlPanel.classList.add('opacity-50', 'pointer-events-none'); + + // 禁用 AFE 控件 + afeLinearGainRange.disabled = true; + agcTargetLevelRange.disabled = true; + agcCompressionGainRange.disabled = true; + saveAfeLinearGainButton.disabled = true; + saveAgcTargetLevelButton.disabled = true; + saveAgcCompressionGainButton.disabled = true; } // 读取Characteristic值 @@ -372,6 +471,81 @@

⚠️ 需要重启设备

} } + // 读取 AFE Linear Gain (字符串格式的 f32) + async function readAfeLinearGain() { + if (!isConnected || !service) return false; + try { + const characteristic = await service.getCharacteristic(AFE_LINEAR_GAIN_ID); + const value = await characteristic.readValue(); + const decoder = new TextDecoder(); + const stringValue = decoder.decode(value); + const gain = parseFloat(stringValue); + if (!isNaN(gain)) { + afeLinearGainRange.value = Math.round(gain * 10); + afeLinearGainValue.textContent = gain.toFixed(1); + // 重置标题(移除不支持标识) + afeLinearGainTitle.textContent = 'AFE 线性增益'; + saveAfeLinearGainButton.disabled = true; + } + return true; + } catch (error) { + console.error('读取 AFE Linear Gain 失败:', error); + // 兼容旧版本:禁用该控件 + afeLinearGainRange.disabled = true; + saveAfeLinearGainButton.disabled = true; + afeLinearGainTitle.textContent = 'AFE 线性增益 (不支持)'; + return false; + } + } + + // 读取 AGC Target Level (i32, 4 bytes little endian) + async function readAgcTargetLevel() { + if (!isConnected || !service) return false; + try { + const characteristic = await service.getCharacteristic(AGC_TARGET_LEVEL_ID); + const value = await characteristic.readValue(); + // value 已经是 DataView,直接使用 + const level = value.getInt32(0, true); + agcTargetLevelRange.value = level; + agcTargetLevelValue.textContent = level; + // 重置标题(移除不支持标识) + agcTargetLevelTitle.textContent = 'AGC 目标电平'; + saveAgcTargetLevelButton.disabled = true; + return true; + } catch (error) { + console.error('读取 AGC Target Level 失败:', error); + // 兼容旧版本:禁用该控件 + agcTargetLevelRange.disabled = true; + saveAgcTargetLevelButton.disabled = true; + agcTargetLevelTitle.textContent = 'AGC 目标电平 (不支持)'; + return false; + } + } + + // 读取 AGC Compression Gain (i32, 4 bytes little endian) + async function readAgcCompressionGain() { + if (!isConnected || !service) return false; + try { + const characteristic = await service.getCharacteristic(AGC_COMPRESSION_GAIN_ID); + const value = await characteristic.readValue(); + // value 已经是 DataView,直接使用 + const gain = value.getInt32(0, true); + agcCompressionGainRange.value = gain; + agcCompressionGainValue.textContent = gain; + // 重置标题(移除不支持标识) + agcCompressionGainTitle.textContent = 'AGC 压缩增益'; + saveAgcCompressionGainButton.disabled = true; + return true; + } catch (error) { + console.error('读取 AGC Compression Gain 失败:', error); + // 兼容旧版本:禁用该控件 + agcCompressionGainRange.disabled = true; + saveAgcCompressionGainButton.disabled = true; + agcCompressionGainTitle.textContent = 'AGC 压缩增益 (不支持)'; + return false; + } + } + // 加载所有配置 async function loadAllConfiguration() { if (!isConnected || !service) { @@ -387,6 +561,11 @@

⚠️ 需要重启设备

await readCharacteristic(PASS_ID, passInput); await readCharacteristic(SERVER_URL_ID, serverUrlInput); + // 加载 AFE 参数 + await readAfeLinearGain(); + await readAgcTargetLevel(); + await readAgcCompressionGain(); + // 清除所有修改标记 clearFieldModification('ssid', ssidTitle); clearFieldModification('pass', passTitle); @@ -617,6 +796,98 @@

⚠️ 需要重启设备

showNotification('信息', '背景图片已清除'); }); + // AFE Linear Gain 保存函数 + async function saveAfeLinearGain() { + if (!isConnected || !service) { + showNotification('错误', '设备未连接', true); + return; + } + try { + const gain = parseFloat((afeLinearGainRange.value / 10).toFixed(1)); + const characteristic = await service.getCharacteristic(AFE_LINEAR_GAIN_ID); + const encoder = new TextEncoder(); + const data = encoder.encode(gain.toString()); + await characteristic.writeValue(data); + showNotification('成功', `AFE 线性增益已设置为 ${gain}`); + clearFieldModification('afeLinearGain', afeLinearGainTitle); + saveAfeLinearGainButton.disabled = true; + } catch (error) { + console.error('保存 AFE Linear Gain 失败:', error); + showNotification('错误', '保存 AFE 线性增益失败: ' + error.message, true); + } + } + + // AGC Target Level 保存函数 + async function saveAgcTargetLevel() { + if (!isConnected || !service) { + showNotification('错误', '设备未连接', true); + return; + } + try { + const level = parseInt(agcTargetLevelRange.value); + const characteristic = await service.getCharacteristic(AGC_TARGET_LEVEL_ID); + const data = new ArrayBuffer(4); + const view = new DataView(data); + view.setInt32(0, level, true); + await characteristic.writeValue(data); + showNotification('成功', `AGC 目标电平已设置为 ${level}`); + clearFieldModification('agcTargetLevel', agcTargetLevelTitle); + saveAgcTargetLevelButton.disabled = true; + } catch (error) { + console.error('保存 AGC Target Level 失败:', error); + showNotification('错误', '保存 AGC 目标电平失败: ' + error.message, true); + } + } + + // AGC Compression Gain 保存函数 + async function saveAgcCompressionGain() { + if (!isConnected || !service) { + showNotification('错误', '设备未连接', true); + return; + } + try { + const gain = parseInt(agcCompressionGainRange.value); + const characteristic = await service.getCharacteristic(AGC_COMPRESSION_GAIN_ID); + const data = new ArrayBuffer(4); + const view = new DataView(data); + view.setInt32(0, gain, true); + await characteristic.writeValue(data); + showNotification('成功', `AGC 压缩增益已设置为 ${gain}`); + clearFieldModification('agcCompressionGain', agcCompressionGainTitle); + saveAgcCompressionGainButton.disabled = true; + } catch (error) { + console.error('保存 AGC Compression Gain 失败:', error); + showNotification('错误', '保存 AGC 压缩增益失败: ' + error.message, true); + } + } + + // AFE Linear Gain 滑块事件 + afeLinearGainRange.addEventListener('input', () => { + const gain = (afeLinearGainRange.value / 10).toFixed(1); + afeLinearGainValue.textContent = gain; + markFieldAsModified('afeLinearGain', afeLinearGainTitle); + saveAfeLinearGainButton.disabled = false; + }); + + // AGC Target Level 滑块事件 + agcTargetLevelRange.addEventListener('input', () => { + agcTargetLevelValue.textContent = agcTargetLevelRange.value; + markFieldAsModified('agcTargetLevel', agcTargetLevelTitle); + saveAgcTargetLevelButton.disabled = false; + }); + + // AGC Compression Gain 滑块事件 + agcCompressionGainRange.addEventListener('input', () => { + agcCompressionGainValue.textContent = agcCompressionGainRange.value; + markFieldAsModified('agcCompressionGain', agcCompressionGainTitle); + saveAgcCompressionGainButton.disabled = false; + }); + + // AFE 保存按钮事件 + saveAfeLinearGainButton.addEventListener('click', saveAfeLinearGain); + saveAgcTargetLevelButton.addEventListener('click', saveAgcTargetLevel); + saveAgcCompressionGainButton.addEventListener('click', saveAgcCompressionGain); + // 检查浏览器是否支持Web Bluetooth API if (!navigator.bluetooth) { showNotification('错误', '您的浏览器不支持Web Bluetooth API,请使用Chrome或Edge浏览器', true); diff --git a/src/audio.rs b/src/audio.rs index 0c9b52d..51db947 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -7,6 +7,10 @@ use esp_idf_svc::sys::esp_sr; const SAMPLE_RATE: u32 = 16000; +pub static mut AFE_LINEAR_GAIN: f32 = 1.0; +pub static mut AGC_TARGET_LEVEL_DBFS: i32 = 3; +pub static mut AGC_COMPRESSION_GAIN_DB: i32 = 9; + unsafe fn afe_init() -> ( *mut esp_sr::esp_afe_sr_iface_t, *mut esp_sr::esp_afe_sr_data_t, @@ -29,7 +33,9 @@ unsafe fn afe_init() -> ( afe_config.vad_mode = esp_sr::vad_mode_t_VAD_MODE_4; afe_config.agc_init = true; - afe_config.afe_linear_gain = 2.0; + afe_config.afe_linear_gain = AFE_LINEAR_GAIN; + afe_config.agc_target_level_dbfs = AGC_TARGET_LEVEL_DBFS; + afe_config.agc_compression_gain_db = AGC_COMPRESSION_GAIN_DB; afe_config.aec_init = true; afe_config.aec_mode = esp_sr::aec_mode_t_AEC_MODE_VOIP_HIGH_PERF; diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index 50f0b5f..a046824 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -9,10 +9,7 @@ pub const AFE_AEC_OFFSET: usize = 512; pub fn afe_config(afe_config: &mut esp_idf_svc::sys::esp_sr::afe_config_t) { afe_config.agc_init = true; afe_config.agc_mode = esp_idf_svc::sys::esp_sr::afe_agc_mode_t_AFE_AGC_MODE_WEBRTC; - afe_config.afe_linear_gain = 1.5; - afe_config.agc_target_level_dbfs = 1; - afe_config.agc_compression_gain_db = 15; - afe_config.ns_init = false; + afe_config.ns_init = true; } pub fn audio_init(_i2c: I2C0, _sda: Gpio48, _scl: Gpio45) { diff --git a/src/boards/base.rs b/src/boards/base.rs index def7b9e..8284f74 100644 --- a/src/boards/base.rs +++ b/src/boards/base.rs @@ -13,9 +13,6 @@ pub const AFE_AEC_OFFSET: usize = 256; pub fn afe_config(afe_config: &mut esp_idf_svc::sys::esp_sr::afe_config_t) { afe_config.agc_init = true; afe_config.agc_mode = esp_idf_svc::sys::esp_sr::afe_agc_mode_t_AFE_AGC_MODE_WEBRTC; - afe_config.agc_target_level_dbfs = 1; - afe_config.agc_compression_gain_db = 15; - afe_config.afe_linear_gain = 1.0; } pub fn start_audio_workers( diff --git a/src/boards/cube.rs b/src/boards/cube.rs index 76b6a54..7390f12 100644 --- a/src/boards/cube.rs +++ b/src/boards/cube.rs @@ -13,9 +13,6 @@ pub const AFE_AEC_OFFSET: usize = 256; pub fn afe_config(afe_config: &mut esp_idf_svc::sys::esp_sr::afe_config_t) { afe_config.agc_init = true; afe_config.agc_mode = esp_idf_svc::sys::esp_sr::afe_agc_mode_t_AFE_AGC_MODE_WEBRTC; - afe_config.agc_target_level_dbfs = 1; - afe_config.agc_compression_gain_db = 15; - afe_config.afe_linear_gain = 1.0; } pub fn start_audio_workers( diff --git a/src/boards/cube2.rs b/src/boards/cube2.rs index 63880b9..d74e02b 100644 --- a/src/boards/cube2.rs +++ b/src/boards/cube2.rs @@ -13,9 +13,6 @@ pub const AFE_AEC_OFFSET: usize = 256; pub fn afe_config(afe_config: &mut esp_idf_svc::sys::esp_sr::afe_config_t) { afe_config.agc_init = true; afe_config.agc_mode = esp_idf_svc::sys::esp_sr::afe_agc_mode_t_AFE_AGC_MODE_WEBRTC; - afe_config.agc_target_level_dbfs = 1; - afe_config.agc_compression_gain_db = 25; - afe_config.afe_linear_gain = 1.0; } pub fn start_audio_workers( diff --git a/src/bt.rs b/src/bt.rs index 6cc3f46..eddf59e 100644 --- a/src/bt.rs +++ b/src/bt.rs @@ -8,6 +8,9 @@ const PASS_ID: BleUuid = uuid128!("a987ab18-a940-421a-a1d7-b94ee22bccbe"); const SERVER_URL_ID: BleUuid = uuid128!("cef520a9-bcb5-4fc6-87f7-82804eee2b20"); const BACKGROUND_GIF_ID: BleUuid = uuid128!("d1f3b2c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"); const RESET_ID: BleUuid = uuid128!("f0e1d2c3-b4a5-6789-0abc-def123456789"); +const AFE_LINEAR_GAIN_ID: BleUuid = uuid128!("a1b2c3d4-e5f6-4789-0abc-def123456789"); +const AGC_TARGET_LEVEL_ID: BleUuid = uuid128!("b2c3d4e5-f6a7-4890-1bcd-ef2345678901"); +const AGC_COMPRESSION_GAIN_ID: BleUuid = uuid128!("c3d4e5f6-a7b8-4901-2cde-f34567890123"); pub fn bt( device_id: &str, @@ -103,6 +106,7 @@ pub fn bt( let setting = setting.clone(); let setting_ = setting.clone(); let setting_gif = setting.clone(); + let setting_afe = setting.clone(); // Extra clone for AFE characteristics let server_url_characteristic = service.lock().create_characteristic( SERVER_URL_ID, @@ -166,6 +170,107 @@ pub fn bt( } }); + // AFE linear gain characteristic + let setting1 = setting_afe.clone(); + let setting2 = setting_afe.clone(); + let afe_linear_gain_characteristic = service.lock().create_characteristic( + AFE_LINEAR_GAIN_ID, + NimbleProperties::READ | NimbleProperties::WRITE, + ); + afe_linear_gain_characteristic + .lock() + .on_read(move |c, _| { + log::info!("Read from AFE linear gain characteristic"); + let setting = setting1.lock().unwrap(); + let afe_line_gain_str = format!("{}", setting.0.afe_linear_gain); + c.set_value(afe_line_gain_str.as_bytes()); + }) + .on_write(move |args| { + let data = args.recv_data(); + let gain = String::from_utf8(data.to_vec()) + .map(|s| s.parse::().ok()) + .ok() + .flatten(); + + if let Some(gain) = gain { + log::info!("New AFE linear gain: {}", gain); + let mut setting = setting2.lock().unwrap(); + if let Err(e) = setting.1.set_blob("afe_linear_gain", &gain.to_le_bytes()) { + log::error!("Failed to save AFE linear gain to NVS: {:?}", e); + args.reject(); + } else { + setting.0.afe_linear_gain = gain; + } + } else { + log::error!("Failed to parse new AFE linear gain from bytes."); + args.reject(); + } + }); + + // AGC target level characteristic + let setting1 = setting_afe.clone(); + let setting2 = setting_afe.clone(); + let agc_target_level_characteristic = service.lock().create_characteristic( + AGC_TARGET_LEVEL_ID, + NimbleProperties::READ | NimbleProperties::WRITE, + ); + agc_target_level_characteristic + .lock() + .on_read(move |c, _| { + log::info!("Read from AGC target level characteristic"); + let setting = setting1.lock().unwrap(); + c.set_value(setting.0.agc_target_level_dbfs.to_le_bytes().as_ref()); + }) + .on_write(move |args| { + let data = args.recv_data(); + if data.len() == 4 { + let level = i32::from_le_bytes([data[0], data[1], data[2], data[3]]); + log::info!("New AGC target level: {}", level); + let mut setting = setting2.lock().unwrap(); + if let Err(e) = setting.1.set_i32("agc_tl_dbfs", level) { + log::error!("Failed to save AGC target level to NVS: {:?}", e); + args.reject(); + } else { + setting.0.agc_target_level_dbfs = level; + } + } else { + log::error!("Failed to parse new AGC target level from bytes."); + args.reject(); + } + }); + + // AGC compression gain characteristic + let setting1 = setting_afe.clone(); + let setting2 = setting_afe.clone(); + let agc_compression_gain_characteristic = service.lock().create_characteristic( + AGC_COMPRESSION_GAIN_ID, + NimbleProperties::READ | NimbleProperties::WRITE, + ); + agc_compression_gain_characteristic + .lock() + .on_read(move |c, _| { + log::info!("Read from AGC compression gain characteristic"); + let setting = setting1.lock().unwrap(); + c.set_value(setting.0.agc_compression_gain_db.to_le_bytes().as_ref()); + }) + .on_write(move |args| { + let data = args.recv_data(); + if data.len() == 4 { + let gain = i32::from_le_bytes([data[0], data[1], data[2], data[3]]); + log::info!("New AGC compression gain: {}", gain); + let mut setting = setting2.lock().unwrap(); + if let Err(e) = setting.1.set_i32("agc_cg_db", gain) { + log::error!("Failed to save AGC compression gain to NVS: {:?}", e); + args.reject(); + } else { + setting.0.agc_compression_gain_db = gain; + } + } else { + log::error!("Failed to parse new AGC compression gain from bytes."); + args.reject(); + } + }); + ble_advertising.lock().set_data( BLEAdvertisementData::new() .name(&format!("EchoKit-{}", device_id)) diff --git a/src/main.rs b/src/main.rs index 909cf39..d43cd7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,10 @@ struct Setting { server_url: String, background_gif: (Vec, bool), // (data, ended) state: u8, // if 1, enter setup mode + // AFE parameters + afe_linear_gain: f32, + agc_target_level_dbfs: i32, + agc_compression_gain_db: i32, } impl Setting { @@ -91,12 +95,44 @@ impl Setting { let state = nvs.get_u8("state")?.unwrap_or(0); + let mut afe_linear_gain_buf = [0u8; 4]; + let afe_linear_gain = nvs + .get_blob("afe_linear_gain", &mut afe_linear_gain_buf) + .map_err(|e| { + log::error!("Failed to get afe_linear_gain: {:?}", e); + }) + .ok() + .flatten() + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .unwrap_or(unsafe { audio::AFE_LINEAR_GAIN }); + + let agc_target_level_dbfs = nvs + .get_i32("agc_tl_dbfs") + .map_err(|e| { + log::error!("Failed to get agc_target_level_dbfs: {:?}", e); + }) + .ok() + .flatten() + .unwrap_or(unsafe { audio::AGC_TARGET_LEVEL_DBFS }); + + let agc_compression_gain_db = nvs + .get_i32("agc_cg_db") + .map_err(|e| { + log::error!("Failed to get agc_compression_gain_db: {:?}", e); + }) + .ok() + .flatten() + .unwrap_or(unsafe { audio::AGC_COMPRESSION_GAIN_DB }); + Ok(Setting { ssid, pass, server_url, background_gif: (background_gif.to_vec(), false), state, + afe_linear_gain, + agc_target_level_dbfs, + agc_compression_gain_db, }) } @@ -250,6 +286,12 @@ fn main() -> anyhow::Result<()> { unsafe { esp_idf_svc::sys::esp_restart() } } + unsafe { + audio::AFE_LINEAR_GAIN = setting.afe_linear_gain; + audio::AGC_TARGET_LEVEL_DBFS = setting.agc_target_level_dbfs; + audio::AGC_COMPRESSION_GAIN_DB = setting.agc_compression_gain_db; + } + let mut chat_ui = boards::ui::new_chat_ui::<6>(framebuffer.as_mut())?; chat_ui.set_state("Connecting to wifi...".to_string()); From 11c0281afb95843efa25fe42367b5c5b9a33a594 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 04:26:48 +0800 Subject: [PATCH 16/22] optimize base UI rendering performance --- src/boards/mod.rs | 119 +++++++++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 43 deletions(-) diff --git a/src/boards/mod.rs b/src/boards/mod.rs index 64b6c50..5337ce8 100644 --- a/src/boards/mod.rs +++ b/src/boards/mod.rs @@ -245,6 +245,31 @@ pub mod ui { DISPLAY_HEIGHT, { buffer_size::(DISPLAY_WIDTH, DISPLAY_HEIGHT) }, >; + + struct PixelsTarget<'a> { + pixels: &'a mut Vec>, + bounding_box: Rectangle, + } + + impl Dimensions for PixelsTarget<'_> { + fn bounding_box(&self) -> Rectangle { + self.bounding_box + } + } + + impl DrawTarget for PixelsTarget<'_> { + type Color = ColorFormat; + type Error = core::convert::Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + self.pixels.extend(pixels); + Ok(()) + } + } + pub struct FrameBuffer { buffers: Box, background_buffers: Box, @@ -318,10 +343,13 @@ pub mod ui { const AVATAR_SIZE: u32 = 96; pub struct ChatUI { state_text: String, + state_text_pixels: Vec>, asr_text: String, + asr_text_pixels: Vec>, content: String, + content_pixels: Vec>, avatar: DynamicImage, } @@ -330,8 +358,11 @@ pub mod ui { pub fn new(avatar: DynamicImage) -> Self { Self { state_text: String::new(), + state_text_pixels: Vec::with_capacity(DISPLAY_WIDTH * 32), asr_text: String::new(), + asr_text_pixels: Vec::with_capacity(DISPLAY_WIDTH * 32), content: String::new(), + content_pixels: Vec::with_capacity(DISPLAY_WIDTH * DISPLAY_HEIGHT / 4), avatar: avatar, } } @@ -339,18 +370,21 @@ pub mod ui { pub fn set_state(&mut self, text: String) { if self.state_text != text { self.state_text = text; + self.state_text_pixels.clear(); } } pub fn set_asr(&mut self, text: String) { if self.asr_text != text { self.asr_text = text; + self.asr_text_pixels.clear(); } } pub fn set_text(&mut self, text: String) { if self.content != text { self.content = text; + self.content_pixels.clear(); } } @@ -359,50 +393,17 @@ pub mod ui { } pub fn render_to_target(&mut self, target: &mut FrameBuffer) -> anyhow::Result<()> { - self.draw(target) - .map_err(|e| anyhow::anyhow!("Failed to draw ChatUI: {:?}", e))?; - - Ok(()) - } - - pub fn layout(bounding_box: Rectangle) -> (Rectangle, Rectangle, Rectangle) { - let state_area_box = Rectangle::new( - bounding_box.top_left, - Size::new(bounding_box.size.width, 32), - ); - - let asr_area_box = Rectangle::new( - bounding_box.top_left + Point::new(0, 32), - Size::new(bounding_box.size.width, 32), - ); - - let content_height = bounding_box.size.height - 64; - - let content_area_box = Rectangle::new( - bounding_box.top_left + Point::new(0, 64), - Size::new(bounding_box.size.width, content_height), - ); - - (state_area_box, asr_area_box, content_area_box) - } - } - - impl Drawable for ChatUI { - type Color = ColorFormat; - - type Output = (); - - fn draw(&self, target: &mut D) -> Result - where - D: DrawTarget, - { let bounding_box = target.bounding_box(); self.avatar.render(target)?; let (state_area_box, asr_area_box, content_area_box) = Self::layout(bounding_box); - { + if self.state_text_pixels.is_empty() { + let mut pixel_target = PixelsTarget { + pixels: &mut self.state_text_pixels, + bounding_box, + }; Text::with_alignment( &self.state_text, state_area_box.center(), @@ -412,10 +413,15 @@ pub mod ui { ), Alignment::Center, ) - .draw(target)?; + .draw(&mut pixel_target)?; } + target.draw_iter(self.state_text_pixels.iter().cloned())?; - { + if self.asr_text_pixels.is_empty() { + let mut pixel_target = PixelsTarget { + pixels: &mut self.asr_text_pixels, + bounding_box, + }; Text::with_alignment( &self.asr_text, asr_area_box.center(), @@ -425,10 +431,15 @@ pub mod ui { ), Alignment::Center, ) - .draw(target)?; + .draw(&mut pixel_target)?; } + target.draw_iter(self.asr_text_pixels.iter().cloned())?; - { + if self.content_pixels.is_empty() { + let mut pixel_target = PixelsTarget { + pixels: &mut self.content_pixels, + bounding_box, + }; let textbox_style = embedded_text::style::TextBoxStyleBuilder::new() .height_mode(embedded_text::style::HeightMode::FitToText) .alignment(embedded_text::alignment::HorizontalAlignment::Center) @@ -448,11 +459,33 @@ pub mod ui { ), textbox_style, ) - .draw(target)?; + .draw(&mut pixel_target)?; } + target.draw_iter(self.content_pixels.iter().cloned())?; Ok(()) } + + pub fn layout(bounding_box: Rectangle) -> (Rectangle, Rectangle, Rectangle) { + let state_area_box = Rectangle::new( + bounding_box.top_left, + Size::new(bounding_box.size.width, 32), + ); + + let asr_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, 32), + Size::new(bounding_box.size.width, 32), + ); + + let content_height = bounding_box.size.height - 64; + + let content_area_box = Rectangle::new( + bounding_box.top_left + Point::new(0, 64), + Size::new(bounding_box.size.width, content_height), + ); + + (state_area_box, asr_area_box, content_area_box) + } } pub fn new_chat_ui(target: &mut FrameBuffer) -> anyhow::Result> { From 3c0999cff20cd8ceed1548dd60b9ea97206ec107 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Sat, 17 Jan 2026 04:31:13 +0800 Subject: [PATCH 17/22] fix: disable Bluetooth NimBLE host task stack size configuration --- sdkconfig.defaults | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 7a44a6c..395f0e4 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -46,7 +46,7 @@ CONFIG_BT_ENABLED=y CONFIG_BT_BLE_ENABLED=y CONFIG_BT_BLUEDROID_ENABLED=n CONFIG_BT_NIMBLE_ENABLED=y -CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=7000 +#CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=7000 #CONFIG_BT_NIMBLE_NVS_PERSIST=y From fbb57a4407904d1d57d3dfbfcf847e05a7dd55e7 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 19 Jan 2026 21:19:13 +0800 Subject: [PATCH 18/22] chore: update package version to 0.3.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 89a6767..51a1adb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "echokit" -version = "0.2.2" +version = "0.3.0" authors = ["csh <458761603@qq.com>"] edition = "2021" resolver = "2" From 3d91768b97305358c63380e5e6ce68a200b36520 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 19 Jan 2026 21:30:04 +0800 Subject: [PATCH 19/22] fix: Restore DEFAULT_BACKGROUND --- src/ui.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index f8d5006..1800911 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,10 +9,10 @@ use u8g2_fonts::U8g2TextStyle; pub type ColorFormat = Rgb565; -// pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/echokit.gif"); -pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/ht.gif"); +pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/echokit.gif"); +// pub const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../assets/ht.gif"); -pub const LM_PNG: &[u8] = include_bytes!("../assets/lm_320x240.png"); +// pub const LM_PNG: &[u8] = include_bytes!("../assets/lm_320x240.png"); pub const AVATAR_GIF: &[u8] = include_bytes!("../assets/avatar.gif"); // TextRenderer + CharacterStyle From 004f2bdcdd0a627237fe44ac4a8cafd4159dd915 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 19 Jan 2026 21:42:11 +0800 Subject: [PATCH 20/22] fix: build error --- Cargo.toml | 10 +++++----- src/boards/mod.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 51a1adb..cd7da48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,11 +24,11 @@ default = ["boards"] experimental = ["esp-idf-svc/experimental"] -boards = ["voice_interrupt", "base_ui"] +boards = ["voice_interrupt"] _no_default = [] -box = ["_no_default", "voice_interrupt"] -cube = ["_no_default", "voice_interrupt", "base_ui"] -cube2 = ["_no_default", "voice_interrupt", "base_ui"] +box = ["_no_default", "voice_interrupt", "custom_ui"] +cube = ["_no_default", "voice_interrupt"] +cube2 = ["_no_default", "voice_interrupt"] nfc_cube2 = ["cube2", "mfrc522", "exio"] mfrc522 = ["i2c", "dep:ndef", "extra_server"] @@ -38,7 +38,7 @@ extra_server = [] i2c = [] voice_interrupt = [] -base_ui = [] +custom_ui = [] [dependencies] log = "0.4" diff --git a/src/boards/mod.rs b/src/boards/mod.rs index 5337ce8..d9cf167 100644 --- a/src/boards/mod.rs +++ b/src/boards/mod.rs @@ -218,7 +218,7 @@ pub fn set_backlight<'d>( Ok(()) } -#[cfg(feature = "base_ui")] +#[cfg(not(feature = "custom_ui"))] pub mod ui { use super::*; From 241d70f283cf1398d03ac0e61532f0168da13a57 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Mon, 19 Jan 2026 21:47:20 +0800 Subject: [PATCH 21/22] fix: nfc_cube2 build --- src/main.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index d43cd7f..831e860 100644 --- a/src/main.rs +++ b/src/main.rs @@ -183,25 +183,30 @@ fn main() -> anyhow::Result<()> { log_heap(); + let mut chat_ui = boards::ui::new_chat_ui::<6>(framebuffer.as_mut())?; + #[cfg(feature = "extra_server")] { - gui.state = "Initializing...".to_string(); - gui.text = "Loading Server URL...".to_string(); - gui.display_flush().unwrap(); + chat_ui.set_state("Initializing...".to_string()); + chat_ui.set_text("Loading Server URL...".to_string()); + + chat_ui.render_to_target(framebuffer.as_mut())?; + framebuffer.flush()?; while let Some(event) = evt_rx.blocking_recv() { if let app::Event::ServerUrl(url) = event { log::info!("Received ServerUrl event: {}", url); if !url.is_empty() { - server_url = url; + setting.server_url = url; } break; } } std::thread::sleep(std::time::Duration::from_millis(500)); - gui.text = format!("Server URL: {}\nContinuing...", server_url); - gui.display_flush().unwrap(); + chat_ui.set_text(format!("Server URL: {}\nContinuing...", setting.server_url)); + chat_ui.render_to_target(framebuffer.as_mut())?; + framebuffer.flush()?; std::thread::sleep(std::time::Duration::from_millis(2000)); } @@ -292,8 +297,6 @@ fn main() -> anyhow::Result<()> { audio::AGC_COMPRESSION_GAIN_DB = setting.agc_compression_gain_db; } - let mut chat_ui = boards::ui::new_chat_ui::<6>(framebuffer.as_mut())?; - chat_ui.set_state("Connecting to wifi...".to_string()); chat_ui.render_to_target(framebuffer.as_mut())?; framebuffer.flush()?; From dc0926f7a80d3b7853f9c04086c3640ae5dbb57b Mon Sep 17 00:00:00 2001 From: Michael Yuan Date: Tue, 20 Jan 2026 05:51:16 +0800 Subject: [PATCH 22/22] Update src/boards/atom_box.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/boards/atom_box.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index a046824..2c3aedc 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -576,7 +576,7 @@ pub mod ui { Ok(Self { qr_area: ImageArea::new_from_qr_code(qr_area_box, qr_content)?, - info: info.to_string(), + info, }) }