From 672140f37ff2c673c792c9e768551f4e141e28bd Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Wed, 21 Jan 2026 21:35:41 +0800 Subject: [PATCH 01/14] fix: update AGC default value --- src/audio.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/audio.rs b/src/audio.rs index 51db947..5408924 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -7,9 +7,9 @@ use esp_idf_svc::sys::esp_sr; const SAMPLE_RATE: u32 = 16000; -pub static mut AFE_LINEAR_GAIN: f32 = 1.0; +pub static mut AFE_LINEAR_GAIN: f32 = 1.5; pub static mut AGC_TARGET_LEVEL_DBFS: i32 = 3; -pub static mut AGC_COMPRESSION_GAIN_DB: i32 = 9; +pub static mut AGC_COMPRESSION_GAIN_DB: i32 = 15; unsafe fn afe_init() -> ( *mut esp_sr::esp_afe_sr_iface_t, From 18ff9243c17264715b7cd0c695d9d82770903ab7 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Wed, 21 Jan 2026 21:36:25 +0800 Subject: [PATCH 02/14] feat: support server vad --- src/app.rs | 169 +++++++++++++++++------------------------------- src/audio.rs | 71 ++++++++++---------- src/protocol.rs | 2 + src/ws.rs | 14 ++-- 4 files changed, 106 insertions(+), 150 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3e55c4e..fce0086 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,6 @@ pub enum Event { MicAudioChunk(Vec), MicAudioEnd, Vowel(u8), - MicInterruptWaitTimeout, #[cfg_attr(not(feature = "extra_server"), allow(unused))] ServerUrl(String), } @@ -56,18 +55,13 @@ async fn select_evt( server.recv().await } }; - let timeout_event = if timeout == INTERNAL_TIMEOUT { - Some(Event::MicInterruptWaitTimeout) - } else { - Some(Event::Event(Event::IDLE)) - }; let timeout_f = tokio::time::sleep(timeout); tokio::select! { _ = timeout_f => { // log::info!("Event select timeout"); - timeout_event + Some(Event::Event(Event::IDLE)) } Some(evt) = evt_rx.recv() => { match &evt { @@ -83,9 +77,6 @@ async fn select_evt( Event::ServerEvent(_) => { log::info!("[Select] Received ServerEvent: {:?}", evt); }, - Event::MicInterruptWaitTimeout => { - log::info!("[Select] Received MicInterruptWaitTimeout"); - } Event::Vowel(v) => { log::debug!("[Select] Received Vowel: {}", v); } @@ -157,9 +148,22 @@ impl DownloadMetrics { } 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); +struct SubmitState { + submit_audio: f32, + start_submit: bool, + audio_buffer: Vec, +} + +impl SubmitState { + fn clear(&mut self) { + self.submit_audio = 0.0; + self.start_submit = false; + self.audio_buffer.clear(); + } +} + pub async fn main_work<'d, const N: usize>( mut server: Server, player_tx: audio::PlayerTx, @@ -182,10 +186,12 @@ pub async fn main_work<'d, const N: usize>( let mut state = State::Idle; - let mut submit_audio = 0.0; - let mut start_submit = false; + let mut submit_state = SubmitState { + submit_audio: 0.0, + start_submit: false, + audio_buffer: Vec::with_capacity(8192), + }; - let mut audio_buffer = Vec::with_capacity(8192); let mut recv_audio_buffer = Vec::with_capacity(8192); let mut metrics = DownloadMetrics::new(); @@ -200,7 +206,7 @@ pub async fn main_work<'d, const N: usize>( let mut wait_notify = false; let mut init_hello = false; let mut allow_interrupt = false; - let mut timeout = NORMAL_TIMEOUT; + let timeout = NORMAL_TIMEOUT; while let Some(evt) = select_evt(&mut evt_rx, &mut server, ¬ify, wait_notify, timeout).await { @@ -228,9 +234,7 @@ pub async fn main_work<'d, const N: usize>( log::info!("Waiting for hello response"); let _ = hello_notify.notified().await; - start_submit = false; - submit_audio = 0.0; - audio_buffer = Vec::with_capacity(8192); + submit_state.clear(); log::info!("Hello response received"); @@ -313,36 +317,32 @@ pub async fn main_work<'d, const N: usize>( framebuffer.flush()?; } Event::MicAudioChunk(data) if state == State::Listening => { - submit_audio += data.len() as f32 / 16000.0; - audio_buffer.extend_from_slice(&data); + submit_state.submit_audio += data.len() as f32 / 16000.0; + submit_state.audio_buffer.extend_from_slice(&data); - if audio_buffer.len() >= 8192 && submit_audio > 0.5 { - if !start_submit { + if submit_state.audio_buffer.len() >= 8192 && submit_state.submit_audio > 0.5 { + if !submit_state.start_submit { log::info!("Start submitting audio"); server .send_client_command(protocol::ClientCommand::StartChat) .await?; log::info!("Submitted StartChat command"); + gui.set_state("Listening...".to_string()); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; } - 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()?; + submit_state.start_submit = true; + server + .send_client_audio_chunk_i16(submit_state.audio_buffer) + .await?; + submit_state.audio_buffer = Vec::with_capacity(8192); } } Event::MicAudioChunk(data) if state == State::Speaking && allow_interrupt => { - submit_audio += data.len() as f32 / 16000.0; - audio_buffer.extend_from_slice(&data); - if audio_buffer.len() == 0 { - player_tx - .send(AudioEvent::StopSpeech) - .map_err(|_| anyhow::anyhow!("Error sending stop"))?; - } + submit_state.submit_audio += data.len() as f32 / 16000.0; + submit_state.audio_buffer.extend_from_slice(&data); - if submit_audio > 0.6 { + if submit_state.submit_audio > 0.6 { state = State::Listening; gui.set_state("Listening...".to_string()); gui.render_to_target(framebuffer)?; @@ -350,7 +350,7 @@ pub async fn main_work<'d, const N: usize>( server.reconnect_with_retry(3).await?; - start_submit = true; + submit_state.start_submit = true; server .send_client_command(protocol::ClientCommand::StartChat) .await?; @@ -365,76 +365,6 @@ pub async fn main_work<'d, const N: usize>( } Event::MicAudioEnd => { log::info!("Received MicAudioEnd"); - if state != State::Listening && state != State::Speaking { - log::debug!("Received MicAudioEnd while no Listening/Speaking state, ignoring"); - continue; - } - - if state == State::Speaking { - if !allow_interrupt { - log::info!("Interrupt not allowed, ignoring MicAudioEnd"); - continue; - } - log::info!("resuming to Listening state due to MicAudioEnd"); - player_tx - .send(AudioEvent::StartSpeech) - .map_err(|_| anyhow::anyhow!("Error sending stop"))?; - log::info!("Waiting for stop speech response"); - submit_audio = 0.0; - start_submit = false; - audio_buffer.clear(); - continue; - } - - log::info!("submit_audio = {}", submit_audio); - - if submit_audio > 0.5 { - if !audio_buffer.is_empty() { - server.send_client_audio_chunk_i16(audio_buffer).await?; - audio_buffer = Vec::with_capacity(8192); - } - server - .send_client_command(protocol::ClientCommand::Submit) - .await?; - log::info!("Submitted audio"); - need_compute = metrics.is_timeout(); - - submit_audio = 0.0; - start_submit = false; - wait_notify = false; - state = State::Waiting; - gui.set_state("Waiting...".to_string()); - gui.render_to_target(framebuffer)?; - framebuffer.flush()?; - } - } - Event::MicInterruptWaitTimeout => { - log::info!("Received MicInterruptWaitTimeout"); - timeout = NORMAL_TIMEOUT; - if start_submit { - log::info!("Already started submit, ignoring timeout"); - continue; - } - server - .send_client_command(protocol::ClientCommand::StartChat) - .await?; - log::info!("Submitted StartChat command due to interrupt timeout"); - - server.send_client_audio_chunk_i16(audio_buffer).await?; - server - .send_client_command(protocol::ClientCommand::Submit) - .await?; - log::info!("Submitted audio"); - need_compute = metrics.is_timeout(); - - audio_buffer = Vec::with_capacity(8192); - submit_audio = 0.0; - start_submit = false; - wait_notify = false; - state = State::Waiting; - gui.set_state("Waiting...".to_string()); - gui.render_to_target(framebuffer)?; - framebuffer.flush()?; } Event::ServerEvent(ServerEvent::ASR { text }) => { log::info!("Received ASR: {:?}", text); @@ -533,6 +463,10 @@ pub async fn main_work<'d, const N: usize>( Event::ServerEvent(ServerEvent::EndResponse) => { log::info!("Received request end"); + crate::audio::VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); + + submit_state.clear(); + state = State::Listening; gui.set_state("Ready".to_string()); gui.render_to_target(framebuffer)?; @@ -572,6 +506,25 @@ pub async fn main_work<'d, const N: usize>( "Received deprecated AudioChunkWithVowel, please use AudioChunki16 instead" ); } + Event::ServerEvent(ServerEvent::EndVad) => { + log::info!("Received EndVad event from server"); + crate::audio::VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); + + if state != State::Listening && state != State::Speaking { + log::debug!("Received EndVad while no Listening/Speaking state, ignoring"); + continue; + } + + need_compute = metrics.is_timeout(); + + submit_state.clear(); + + wait_notify = false; + state = State::Waiting; + gui.set_state("Waiting...".to_string()); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; + } Event::ServerUrl(url) => { log::info!("Received ServerUrl: {}", url); if url != server.url { diff --git a/src/audio.rs b/src/audio.rs index 5408924..ad9ad8d 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -162,13 +162,24 @@ pub type PlayerRx = tokio::sync::mpsc::UnboundedReceiver; pub type EventTx = tokio::sync::mpsc::Sender; pub type EventRx = tokio::sync::mpsc::Receiver; +pub static VAD_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { log::info!("AFE worker started"); crate::log_heap(); crate::print_stack_high(); let mut speech = false; + let mut send_chunks = 0; loop { + if send_chunks > (16000 * 30) { + log::warn!("Too many chunks without speech end, resetting AFE"); + afe_handle.reset(); + speech = false; + send_chunks = 0; + continue; + } + let result = afe_handle.fetch(); if let Err(_e) = &result { continue; @@ -178,14 +189,33 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { continue; } + let global_vad = VAD_ACTIVE.load(std::sync::atomic::Ordering::Relaxed); + if result.speech { if !speech { log::info!("Speech started"); + VAD_ACTIVE.store(true, std::sync::atomic::Ordering::Relaxed); + speech = true; + send_chunks = 0; } - speech = true; + + // changed by server vad end + if !global_vad { + continue; + } + + log::debug!("Speech detected, sending {} bytes", result.data.len()); + tx.blocking_send(crate::app::Event::MicAudioChunk(result.data)) + .map_err(|_| anyhow::anyhow!("Failed to send data"))?; + send_chunks += 512; + continue; + } + + if global_vad { log::debug!("Speech detected, sending {} bytes", result.data.len()); tx.blocking_send(crate::app::Event::MicAudioChunk(result.data)) .map_err(|_| anyhow::anyhow!("Failed to send data"))?; + send_chunks += 512; continue; } @@ -233,7 +263,6 @@ pub fn player_welcome( pub enum AudioEvent { Hello(Arc), SetHello(Vec), - StopSpeech, StartSpeech, ClearSpeech, SpeechChunki16(Vec), @@ -422,9 +451,6 @@ impl RingBuffer { } } -static PLAYING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -static VOL_NUM: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(5); - const CHUNK_SIZE: usize = 256; // const CHUNK_SIZE: usize = 512; @@ -471,17 +497,14 @@ fn audio_task_run( let offset = crate::boards::AFE_AEC_OFFSET; let mut hello_wav = WAKE_WAV.to_vec(); - let mut allow_speech = false; - let mut speech = false; - send_buffer.volume = VOL_NUM.load(std::sync::atomic::Ordering::Relaxed) as i16; + send_buffer.volume = 5; loop { if let Ok(event) = rx.try_recv() { match event { AudioEvent::Hello(notify) => { log::info!("Received Hello event"); - allow_speech = true; send_buffer.clear(); send_buffer.push_u8(&hello_wav); send_buffer.push_back_end_speech(notify); @@ -489,12 +512,7 @@ fn audio_task_run( AudioEvent::SetHello(hello) => { hello_wav = hello; } - AudioEvent::StartSpeech => { - allow_speech = true; - } - AudioEvent::StopSpeech => { - allow_speech = false; - } + AudioEvent::StartSpeech => {} AudioEvent::ClearSpeech => { send_buffer.clear(); } @@ -510,20 +528,11 @@ fn audio_task_run( send_buffer.push_back_end_speech(sender); } AudioEvent::VolSet(vol) => { - #[cfg(not(feature = "box"))] - { - send_buffer.volume = vol as i16; - } - #[cfg(feature = "box")] - { - crate::boards::atom_box::set_volum(vol); - } - - VOL_NUM.store(vol, std::sync::atomic::Ordering::Relaxed); + send_buffer.volume = vol as i16; } } } - let play_data_ = if allow_speech { + let play_data_ = { loop { break match send_buffer.get_chunk() { Some(SendBufferItem::Audio(v)) => Some(v), @@ -538,18 +547,8 @@ fn audio_task_run( None => None, }; } - } else { - None }; - if play_data_.is_some() && !speech { - speech = true; - PLAYING.store(speech, std::sync::atomic::Ordering::Relaxed); - } else if play_data_.is_none() && speech { - speech = false; - PLAYING.store(speech, std::sync::atomic::Ordering::Relaxed); - } - let play_data = play_data_.as_deref().unwrap_or(&empty_buffer); fn_write(play_data)?; diff --git a/src/protocol.rs b/src/protocol.rs index 4c24d3b..a533947 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -17,6 +17,8 @@ pub enum ServerEvent { StartVideo, EndVideo, EndResponse, + + EndVad, } #[test] diff --git a/src/ws.rs b/src/ws.rs index 06c9f00..61ee231 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -189,12 +189,14 @@ pub struct Server { rx: tokio::sync::mpsc::Receiver, } +const EXTRA_PARAMETERS: &str = "opus=true&vowel=true&server_vad=true"; + impl Server { pub async fn new(id: String, url: String) -> anyhow::Result { let u = if url.ends_with("/") { - format!("{}{}?opus=true&vowel=true", url, id) + format!("{}{}?{}", url, id, EXTRA_PARAMETERS) } else { - format!("{}/{}?opus=true&vowel=true", url, id) + format!("{}/{}?{}", url, id, EXTRA_PARAMETERS) }; let (ws, _resp) = tokio_websockets::ClientBuilder::new() @@ -227,13 +229,13 @@ impl Server { pub async fn reconnect(&mut self) -> anyhow::Result<()> { let u = if self.url.ends_with("/") { format!( - "{}{}?reconnect=true&opus=true&vowel=true", - self.url, self.id + "{}{}?reconnect=true&{}", + self.url, self.id, EXTRA_PARAMETERS ) } else { format!( - "{}/{}?reconnect=true&opus=true&vowel=true", - self.url, self.id + "{}/{}?reconnect=true&{}", + self.url, self.id, EXTRA_PARAMETERS ) }; From dd5ea17b24e269ba76be927da9608d0a7d20c12b Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Wed, 21 Jan 2026 21:43:55 +0800 Subject: [PATCH 03/14] fix: reduce DownloadMetrics timeout from 30 to 15 seconds --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index fce0086..5a39e19 100644 --- a/src/app.rs +++ b/src/app.rs @@ -121,7 +121,7 @@ impl DownloadMetrics { Self { start_time: std::time::Instant::now() - std::time::Duration::from_secs(300), data_size: 0, - timeout_sec: 30, + timeout_sec: 15, } } From 9dbc2a9817a6512005eca94a892fbead392fa435 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Thu, 22 Jan 2026 18:21:45 +0800 Subject: [PATCH 04/14] perf: Swap event priority --- src/app.rs | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5a39e19..1fce8c9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -63,6 +63,23 @@ async fn select_evt( // log::info!("Event select timeout"); Some(Event::Event(Event::IDLE)) } + Ok(msg) = s_fut => { + match msg { + Event::ServerEvent(ServerEvent::AudioChunk { .. })=>{ + log::debug!("[Select] Received AudioChunk"); + } + Event::ServerEvent(ServerEvent::AudioChunki16 { .. })=>{ + log::debug!("[Select] Received AudioChunki16"); + } + Event::ServerEvent(ServerEvent::HelloChunk { .. })=>{ + log::debug!("[Select] Received HelloChunk"); + } + _=> { + log::debug!("[Select] Received message: {:?}", msg); + } + } + Some(msg) + } Some(evt) = evt_rx.recv() => { match &evt { Event::Event(_) => { @@ -86,23 +103,6 @@ async fn select_evt( } Some(evt) } - Ok(msg) = s_fut => { - match msg { - Event::ServerEvent(ServerEvent::AudioChunk { .. })=>{ - log::debug!("[Select] Received AudioChunk"); - } - Event::ServerEvent(ServerEvent::AudioChunki16 { .. })=>{ - log::debug!("[Select] Received AudioChunki16"); - } - Event::ServerEvent(ServerEvent::HelloChunk { .. })=>{ - log::debug!("[Select] Received HelloChunk"); - } - _=> { - log::debug!("[Select] Received message: {:?}", msg); - } - } - Some(msg) - } else => { log::info!("No events"); None From d19e67351f9c744bac6243d349d7273b4a6444fd Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Thu, 22 Jan 2026 18:27:14 +0800 Subject: [PATCH 05/14] fix: restore alpha value --- src/boards/atom_box.rs | 5 ++--- src/boards/mod.rs | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index a046824..2d976bc 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -537,7 +537,7 @@ pub mod ui { .fill_color(ColorFormat::CSS_DARK_CYAN) .build(); - let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.15); + let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.5); target.draw_iter(pixels)?; let content_style = PrimitiveStyleBuilder::new() @@ -545,8 +545,7 @@ pub mod ui { .stroke_width(5) .fill_color(ColorFormat::CSS_BLACK) .build(); - let pixels = - crate::ui::get_background_pixels(target, content_area_box, content_style, 0.25); + let pixels = crate::ui::get_background_pixels(target, content_area_box, content_style, 0.5); target.draw_iter(pixels)?; target.background_buffers.clone_from(&target.buffers); diff --git a/src/boards/mod.rs b/src/boards/mod.rs index d9cf167..25b9af5 100644 --- a/src/boards/mod.rs +++ b/src/boards/mod.rs @@ -516,7 +516,7 @@ pub mod ui { .fill_color(ColorFormat::CSS_DARK_CYAN) .build(); - let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.15); + let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.5); target.draw_iter(pixels)?; let content_style = PrimitiveStyleBuilder::new() @@ -524,8 +524,7 @@ pub mod ui { .stroke_width(5) .fill_color(ColorFormat::CSS_BLACK) .build(); - let pixels = - crate::ui::get_background_pixels(target, content_area_box, content_style, 0.25); + let pixels = crate::ui::get_background_pixels(target, content_area_box, content_style, 0.5); target.draw_iter(pixels)?; target.background_buffers.clone_from(&target.buffers); From 26a95f2417d2ccb8512f0e4ac37237ac1bb1b8f6 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Thu, 22 Jan 2026 18:33:38 +0800 Subject: [PATCH 06/14] feat: support multiple ASR --- src/app.rs | 34 +++++++++++++++++----------------- src/audio.rs | 9 +++++---- src/ws.rs | 2 +- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1fce8c9..e83f34c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -225,6 +225,8 @@ pub async fn main_work<'d, const N: usize>( gui.render_to_target(framebuffer)?; framebuffer.flush()?; + crate::audio::VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); + server.reconnect_with_retry(3).await?; let hello_notify = Arc::new(tokio::sync::Notify::new()); @@ -320,18 +322,19 @@ pub async fn main_work<'d, const N: usize>( submit_state.submit_audio += data.len() as f32 / 16000.0; submit_state.audio_buffer.extend_from_slice(&data); - if submit_state.audio_buffer.len() >= 8192 && submit_state.submit_audio > 0.5 { - if !submit_state.start_submit { - log::info!("Start submitting audio"); - server - .send_client_command(protocol::ClientCommand::StartChat) - .await?; - log::info!("Submitted StartChat command"); - gui.set_state("Listening...".to_string()); - gui.render_to_target(framebuffer)?; - framebuffer.flush()?; - } + if !submit_state.start_submit { + log::info!("Start submitting audio"); + server + .send_client_command(protocol::ClientCommand::StartChat) + .await?; + log::info!("Submitted StartChat command"); + gui.set_state("Listening...".to_string()); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; submit_state.start_submit = true; + } + + if submit_state.audio_buffer.len() >= 8192 && submit_state.submit_audio > 0.3 { server .send_client_audio_chunk_i16(submit_state.audio_buffer) .await?; @@ -361,14 +364,14 @@ pub async fn main_work<'d, const N: usize>( } } Event::MicAudioChunk(_) => { - log::debug!("Received MicAudioChunk while no Listening/Speaking state, ignoring"); + log::info!("Received MicAudioChunk while no Listening/Speaking state, ignoring"); + audio::VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); } Event::MicAudioEnd => { log::info!("Received MicAudioEnd"); } 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.render_to_target(framebuffer)?; @@ -382,10 +385,7 @@ pub async fn main_work<'d, const N: usize>( } Event::ServerEvent(ServerEvent::StartAudio { text }) => { start_audio = true; - if state != State::Speaking { - log::debug!("Received StartAudio while not in speaking state"); - continue; - } + state = State::Speaking; log::info!("Received audio start: {:?}", text); gui.set_state(format!("[{:.2}x]|Speaking...", speed)); gui.set_text(text.trim().to_string()); diff --git a/src/audio.rs b/src/audio.rs index ad9ad8d..cfb6d5c 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -28,7 +28,7 @@ unsafe fn afe_init() -> ( afe_config.pcm_config.sample_rate = 16000; afe_config.afe_ringbuf_size = 40; afe_config.vad_min_noise_ms = 400; - afe_config.vad_min_speech_ms = 250; + afe_config.vad_min_speech_ms = 200; // afe_config.vad_delay_ms = 250; // Don't change it!! afe_config.vad_mode = esp_sr::vad_mode_t_VAD_MODE_4; @@ -172,11 +172,12 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { let mut send_chunks = 0; loop { - if send_chunks > (16000 * 30) { + if send_chunks > (16000 * 60) / 512 { log::warn!("Too many chunks without speech end, resetting AFE"); afe_handle.reset(); speech = false; send_chunks = 0; + VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); continue; } @@ -207,7 +208,7 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { log::debug!("Speech detected, sending {} bytes", result.data.len()); tx.blocking_send(crate::app::Event::MicAudioChunk(result.data)) .map_err(|_| anyhow::anyhow!("Failed to send data"))?; - send_chunks += 512; + send_chunks += 1; continue; } @@ -215,7 +216,7 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { log::debug!("Speech detected, sending {} bytes", result.data.len()); tx.blocking_send(crate::app::Event::MicAudioChunk(result.data)) .map_err(|_| anyhow::anyhow!("Failed to send data"))?; - send_chunks += 512; + send_chunks += 1; continue; } diff --git a/src/ws.rs b/src/ws.rs index 61ee231..43c8f3d 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -189,7 +189,7 @@ pub struct Server { rx: tokio::sync::mpsc::Receiver, } -const EXTRA_PARAMETERS: &str = "opus=true&vowel=true&server_vad=true"; +const EXTRA_PARAMETERS: &str = "opus=true&vowel=true&stream_asr=true"; impl Server { pub async fn new(id: String, url: String) -> anyhow::Result { From 0175bc0f8362e35cfbb663958dab080f73df498d Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Fri, 23 Jan 2026 03:51:08 +0800 Subject: [PATCH 07/14] fix: setup ui --- src/boards/atom_box.rs | 14 ++++++++++++++ src/boards/base.rs | 7 ++++++- src/boards/mod.rs | 7 +++++++ src/main.rs | 3 ++- src/ui.rs | 1 + 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index 2d976bc..fb153b0 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -290,6 +290,20 @@ pub mod ui { s } + fn fill_color(&mut self, color: ColorFormat) -> anyhow::Result<()> { + for (i, buffer) in self.buffers.iter_mut().enumerate() { + buffer.clear(color)?; + self.diff_indexs.push(i); + self.draw_mask[i] = 1; + } + + for buffer in self.background_buffers.iter_mut() { + buffer.clear(color)?; + } + + Ok(()) + } + fn flush(&mut self) -> anyhow::Result<()> { unsafe { let panel_handle = std::mem::transmute(esp_idf_svc::sys::hal_driver::panel_handle); diff --git a/src/boards/base.rs b/src/boards/base.rs index 8284f74..4cb2e7e 100644 --- a/src/boards/base.rs +++ b/src/boards/base.rs @@ -226,7 +226,12 @@ macro_rules! start_hal { log::error!("Failed to initialize I2C: {:?}", e); } } - }}; + } + let _backlight = { + let mut backlight = crate::boards::backlight_init($peripherals.pins.gpio42.into()).unwrap(); + crate::boards::set_backlight(&mut backlight, 70).unwrap(); + backlight + };}; } #[macro_export] diff --git a/src/boards/mod.rs b/src/boards/mod.rs index 25b9af5..f52eefb 100644 --- a/src/boards/mod.rs +++ b/src/boards/mod.rs @@ -317,6 +317,13 @@ pub mod ui { s } + + fn fill_color(&mut self, color: ColorFormat) -> anyhow::Result<()> { + self.buffers.clear(color)?; + self.background_buffers.clear(color)?; + Ok(()) + } + fn flush(&mut self) -> anyhow::Result<()> { let bounding_box = self.bounding_box(); let x_start = bounding_box.top_left.x as i32; diff --git a/src/main.rs b/src/main.rs index 831e860..5e2e28d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Mutex}; use embedded_graphics::{ - prelude::{Dimensions, RgbColor}, + prelude::{Dimensions, RgbColor, WebColors}, Drawable, }; use esp_idf_svc::eventloop::EspSystemEventLoop; @@ -230,6 +230,7 @@ fn main() -> anyhow::Result<()> { let version = env!("CARGO_PKG_VERSION"); + framebuffer.fill_color(ui::ColorFormat::CSS_GRAY)?; 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())?; diff --git a/src/ui.rs b/src/ui.rs index 1800911..d79975f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -95,6 +95,7 @@ pub trait DisplayTargetDrive: DrawTarget + GetPixel { fn new(color: ColorFormat) -> Self; + fn fill_color(&mut self, color: ColorFormat) -> anyhow::Result<()>; fn flush(&mut self) -> anyhow::Result<()>; fn fix_background(&mut self) -> anyhow::Result<()>; } From 7998e3c4a07d76da7712fa51768ead8279a08b73 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Fri, 23 Jan 2026 04:33:47 +0800 Subject: [PATCH 08/14] fix: afe logic --- src/app.rs | 20 ++++++++++++++++++ src/audio.rs | 59 +++++++++++++++++++++++++++++++++++----------------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/app.rs b/src/app.rs index e83f34c..f6a329f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -154,6 +154,7 @@ struct SubmitState { submit_audio: f32, start_submit: bool, audio_buffer: Vec, + got_asr_result: bool, } impl SubmitState { @@ -161,6 +162,7 @@ impl SubmitState { self.submit_audio = 0.0; self.start_submit = false; self.audio_buffer.clear(); + self.got_asr_result = false; } } @@ -190,6 +192,7 @@ pub async fn main_work<'d, const N: usize>( submit_audio: 0.0, start_submit: false, audio_buffer: Vec::with_capacity(8192), + got_asr_result: false, }; let mut recv_audio_buffer = Vec::with_capacity(8192); @@ -332,6 +335,7 @@ pub async fn main_work<'d, const N: usize>( gui.render_to_target(framebuffer)?; framebuffer.flush()?; submit_state.start_submit = true; + submit_state.got_asr_result = false; } if submit_state.audio_buffer.len() >= 8192 && submit_state.submit_audio > 0.3 { @@ -339,6 +343,19 @@ pub async fn main_work<'d, const N: usize>( .send_client_audio_chunk_i16(submit_state.audio_buffer) .await?; submit_state.audio_buffer = Vec::with_capacity(8192); + + if submit_state.submit_audio > 10.0 && !submit_state.got_asr_result { + log::info!("No ASR result after 10s audio, ending request"); + crate::audio::VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); + + submit_state.clear(); + + state = State::Listening; + gui.set_state("Ready".to_string()); + gui.render_to_target(framebuffer)?; + framebuffer.flush()?; + recv_audio_buffer.clear(); + } } } Event::MicAudioChunk(data) if state == State::Speaking && allow_interrupt => { @@ -354,6 +371,8 @@ pub async fn main_work<'d, const N: usize>( server.reconnect_with_retry(3).await?; submit_state.start_submit = true; + submit_state.got_asr_result = false; + server .send_client_command(protocol::ClientCommand::StartChat) .await?; @@ -372,6 +391,7 @@ pub async fn main_work<'d, const N: usize>( } Event::ServerEvent(ServerEvent::ASR { text }) => { log::info!("Received ASR: {:?}", text); + submit_state.got_asr_result = true; gui.set_state("ASR".to_string()); gui.set_asr(text.trim().to_string()); gui.render_to_target(framebuffer)?; diff --git a/src/audio.rs b/src/audio.rs index cfb6d5c..f2d3906 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,3 +1,4 @@ +use std::collections::LinkedList; use std::sync::Arc; use esp_idf_svc::hal::gpio::AnyIOPin; @@ -116,6 +117,7 @@ impl AFE { unsafe { (afe_handle.as_ref().unwrap().feed.unwrap())(afe_data, data.as_ptr()) } } + #[allow(dead_code)] fn fetch(&self) -> Result { let afe_handle = self.handle; let afe_data = self.data; @@ -153,6 +155,31 @@ impl AFE { Ok(AFEResult { data, speech }) } } + + fn fetch_without_cache(&self) -> Result { + let afe_handle = self.handle; + let afe_data = self.data; + unsafe { + let result = (afe_handle.as_ref().unwrap().fetch.unwrap())(afe_data) + .as_mut() + .unwrap(); + + if result.ret_value != 0 { + return Err(result.ret_value); + } + + let data_size = result.data_size; + let speech = result.vad_state == esp_sr::vad_state_t_VAD_SPEECH; + + let mut data = Vec::with_capacity((data_size) as usize / 2); + if data_size > 0 { + let data_ = std::slice::from_raw_parts(result.data, data_size as usize / 2); + data.extend_from_slice(data_); + } + + Ok(AFEResult { data, speech }) + } + } } pub static WAKE_WAV: &[u8] = include_bytes!("../assets/hello_beep.wav"); @@ -169,19 +196,11 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { crate::log_heap(); crate::print_stack_high(); let mut speech = false; - let mut send_chunks = 0; + let mut audio_cache: LinkedList> = LinkedList::new(); + const MAX_SAMPLE_CACHE: usize = 16; // per chunk is 512 samples = 32ms at 16kHz loop { - if send_chunks > (16000 * 60) / 512 { - log::warn!("Too many chunks without speech end, resetting AFE"); - afe_handle.reset(); - speech = false; - send_chunks = 0; - VAD_ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); - continue; - } - - let result = afe_handle.fetch(); + let result = afe_handle.fetch_without_cache(); if let Err(_e) = &result { continue; } @@ -197,18 +216,16 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { log::info!("Speech started"); VAD_ACTIVE.store(true, std::sync::atomic::Ordering::Relaxed); speech = true; - send_chunks = 0; - } - - // changed by server vad end - if !global_vad { - continue; + while let Some(data) = audio_cache.pop_front() { + log::debug!("Sending cached {} bytes", data.len()); + tx.blocking_send(crate::app::Event::MicAudioChunk(data)) + .map_err(|_| anyhow::anyhow!("Failed to send data"))?; + } } log::debug!("Speech detected, sending {} bytes", result.data.len()); tx.blocking_send(crate::app::Event::MicAudioChunk(result.data)) .map_err(|_| anyhow::anyhow!("Failed to send data"))?; - send_chunks += 1; continue; } @@ -216,7 +233,6 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { log::debug!("Speech detected, sending {} bytes", result.data.len()); tx.blocking_send(crate::app::Event::MicAudioChunk(result.data)) .map_err(|_| anyhow::anyhow!("Failed to send data"))?; - send_chunks += 1; continue; } @@ -227,6 +243,11 @@ fn afe_worker(afe_handle: Arc, tx: EventTx) -> anyhow::Result<()> { speech = false; } + + audio_cache.push_back(result.data); + if audio_cache.len() > MAX_SAMPLE_CACHE { + audio_cache.pop_front(); + } } } From 50735589d9157701cf3bafca2cac26afd689dede Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Fri, 23 Jan 2026 04:55:47 +0800 Subject: [PATCH 09/14] ui(atom): allow empty avatar --- src/boards/atom_box.rs | 55 ++++++++++++++++++++++++++++++++++++------ src/main.rs | 2 +- src/ui.rs | 11 ++++++++- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/boards/atom_box.rs b/src/boards/atom_box.rs index fb153b0..81dc091 100644 --- a/src/boards/atom_box.rs +++ b/src/boards/atom_box.rs @@ -425,8 +425,10 @@ pub mod ui { } pub fn set_avatar_index(&mut self, index: usize) { - self.avatar.set_index(index); - self.avatar_updated = true; + if !self.avatar.image_data.is_empty() { + self.avatar.set_index(index); + self.avatar_updated = true; + } } pub fn clear_update_flags(&mut self) { @@ -438,7 +440,13 @@ pub mod ui { 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); + + let (state_area_box, asr_area_box, content_area_box) = + if self.avatar.image_data.is_empty() { + Self::layout_without_avatar(bounding_box) + } else { + Self::layout(bounding_box) + }; let mut start_i = 0; @@ -511,6 +519,25 @@ pub mod ui { Ok(()) } + pub fn layout_without_avatar(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, 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 layout(bounding_box: Rectangle) -> (Rectangle, Rectangle, Rectangle) { let state_area_box = Rectangle::new( bounding_box.top_left + Point::new(96, 0), @@ -531,11 +558,18 @@ pub mod ui { } } - pub fn new_chat_ui(target: &mut BoxFrameBuffer) -> anyhow::Result> { + pub fn new_chat_ui( + target: &mut BoxFrameBuffer, + avatar_gif: &[u8], + ) -> 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_area_box, asr_area_box, content_area_box) = if avatar_gif.is_empty() { + ChatUI::::layout_without_avatar(bounding_box) + } else { + ChatUI::::layout(bounding_box) + }; let state_style = PrimitiveStyleBuilder::new() .stroke_color(ColorFormat::CSS_DARK_BLUE) .stroke_width(1) @@ -546,9 +580,9 @@ pub mod ui { target.draw_iter(pixels)?; let asr_style = PrimitiveStyleBuilder::new() - .stroke_color(ColorFormat::CSS_DARK_CYAN) + .stroke_color(ColorFormat::CSS_DARK_SLATE_GRAY) .stroke_width(1) - .fill_color(ColorFormat::CSS_DARK_CYAN) + .fill_color(ColorFormat::CSS_DARK_SLATE_GRAY) .build(); let pixels = crate::ui::get_background_pixels(target, asr_area_box, asr_style, 0.5); @@ -566,7 +600,12 @@ pub mod ui { target.flush()?; - let avatar = DynamicImage::new_from_gif(avatar_area_box, crate::ui::AVATAR_GIF)?; + let avatar = if avatar_gif.is_empty() { + DynamicImage::empty() + } else { + DynamicImage::new_from_gif(avatar_area_box, avatar_gif).unwrap_or(DynamicImage::empty()) + }; + Ok(ChatUI::new(avatar)) } diff --git a/src/main.rs b/src/main.rs index 5e2e28d..9ff7f10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -183,7 +183,7 @@ fn main() -> anyhow::Result<()> { log_heap(); - let mut chat_ui = boards::ui::new_chat_ui::<6>(framebuffer.as_mut())?; + let mut chat_ui = boards::ui::new_chat_ui::<6>(framebuffer.as_mut(), &[])?; #[cfg(feature = "extra_server")] { diff --git a/src/ui.rs b/src/ui.rs index d79975f..739ebb8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -357,6 +357,13 @@ pub struct DynamicImage { } impl DynamicImage { + pub fn empty() -> Self { + Self { + display_index: 0, + image_data: Vec::new(), + } + } + 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))?; @@ -405,7 +412,9 @@ impl DynamicImage { &self, display: &mut D, ) -> Result<(), D::Error> { - display.draw_iter(self.image_data[self.display_index].iter().cloned())?; + if !self.image_data.is_empty() { + display.draw_iter(self.image_data[self.display_index].iter().cloned())?; + } Ok(()) } } From dfb5a7c2d222d017c9e45c9805674c3bceab15c8 Mon Sep 17 00:00:00 2001 From: csh <458761603@qq.com> Date: Fri, 23 Jan 2026 05:28:15 +0800 Subject: [PATCH 10/14] feat: support setting avatar gif by bt --- setup/index.html | 155 ++++++++++++++++++-- setup/index_zh.html | 341 +++++++++++++++++++++++++++++++------------- src/bt.rs | 35 +++++ src/main.rs | 59 +++++++- src/ui.rs | 6 +- 5 files changed, 481 insertions(+), 115 deletions(-) diff --git a/setup/index.html b/setup/index.html index bc1dbf6..f79350e 100644 --- a/setup/index.html +++ b/setup/index.html @@ -93,6 +93,31 @@

Background Image

+
+
+

Avatar Image

+
+ + + +
+ +
+
+ + +
+
+
+
@@ -193,6 +218,7 @@

⚠️ Device Reset Required

const PASS_ID = "a987ab18-a940-421a-a1d7-b94ee22bccbe"; const SERVER_URL_ID = "cef520a9-bcb5-4fc6-87f7-82804eee2b20"; const BACKGROUND_IMAGE_ID = "d1f3b2c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + const AVATAR_IMAGE_ID = "e2f4c3b5-6d7e-4f8a-9b0c-1f2e3d4c5b6a"; 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"; @@ -205,6 +231,7 @@

⚠️ Device Reset Required

let isConnected = false; let toast = null; let selectedBackgroundFile = null; + let selectedAvatarFile = null; // DOM const connectButton = document.getElementById('connectButton'); @@ -224,6 +251,11 @@

⚠️ Device Reset Required

const fileError = document.getElementById('fileError'); const writeBgButton = document.getElementById('writeBgButton'); const clearBgButton = document.getElementById('clearBgButton'); + const avatarImage = document.getElementById('avatarImage'); + const avatarPreview = document.getElementById('avatarPreview'); + const avatarFileError = document.getElementById('avatarFileError'); + const writeAvatarButton = document.getElementById('writeAvatarButton'); + const clearAvatarButton = document.getElementById('clearAvatarButton'); const notificationToast = document.getElementById('notificationToast'); const toastMessage = document.getElementById('toastMessage'); const resetNotSupportedModal = document.getElementById('resetNotSupportedModal'); @@ -331,14 +363,46 @@

⚠️ Device Reset Required

return true; } - // background image - function applyBackgroundImage(imageDataUrl) { - document.body.style.backgroundImage = `url(${imageDataUrl})`; + // validate and preview avatar + function validateAndPreviewAvatarFile(input) { + const file = input.files[0]; + avatarFileError.textContent = ''; + avatarPreview.classList.add('hidden'); + writeAvatarButton.disabled = true; + selectedAvatarFile = null; + + if (file) { + // check file type + if (!file.type.startsWith('image/gif')) { + avatarFileError.textContent = 'Please select a GIF image file'; + input.value = ''; + return false; + } + + // check file size (128KB = 128 * 1024 bytes) + const maxSize = 128 * 1024; // 128KB + if (file.size > maxSize) { + avatarFileError.textContent = 'The image file size cannot exceed 128KB'; + input.value = ''; + return false; + } + + // preview image + const reader = new FileReader(); + reader.onload = function (e) { + avatarPreview.style.backgroundImage = `url(${e.target.result})`; + avatarPreview.classList.remove('hidden'); + selectedAvatarFile = file; + writeAvatarButton.disabled = false; + }; + reader.readAsDataURL(file); + } + return true; } // clear background image function clearBackgroundImage() { - document.body.style.backgroundImage = ''; + // No longer changes page background } // connect @@ -424,6 +488,8 @@

⚠️ Device Reset Required

serverUrlInput.disabled = false; backgroundImage.disabled = false; clearBgButton.disabled = false; + avatarImage.disabled = false; + clearAvatarButton.disabled = false; controlPanel.classList.remove('opacity-50', 'pointer-events-none'); // Enable AFE controls @@ -442,6 +508,9 @@

⚠️ Device Reset Required

backgroundImage.disabled = true; writeBgButton.disabled = true; clearBgButton.disabled = true; + avatarImage.disabled = true; + writeAvatarButton.disabled = true; + clearAvatarButton.disabled = true; controlPanel.classList.add('opacity-50', 'pointer-events-none'); // Disable AFE controls @@ -660,13 +729,6 @@

⚠️ Device Reset Required

await new Promise(resolve => setTimeout(resolve, 50)); } - // background image - const reader = new FileReader(); - reader.onload = function (e) { - applyBackgroundImage(e.target.result); - }; - reader.readAsDataURL(selectedBackgroundFile); - // reset the button writeBgButton.disabled = false; writeBgButton.textContent = 'Set Background'; @@ -684,6 +746,64 @@

⚠️ Device Reset Required

} } + async function writeAvatarImage() { + if (!isConnected || !service) { + showNotification('Error', 'EchoKit is not connected', true); + return; + } + + if (!selectedAvatarFile) { + showNotification('Error', 'Please select an avatar image', true); + return; + } + + try { + const characteristic = await service.getCharacteristic(AVATAR_IMAGE_ID); + + const arrayBuffer = await selectedAvatarFile.arrayBuffer(); + const totalSize = arrayBuffer.byteLength; + const chunkSize = 512; // BLE limit + const totalChunks = Math.ceil(totalSize / chunkSize); + + showNotification('Message', `Sending avatar ${totalChunks} data chunks ...`); + + // prevent double clicking + writeAvatarButton.disabled = true; + writeAvatarButton.textContent = 'Sending data ...'; + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, totalSize); + const chunk = arrayBuffer.slice(start, end); + + const packet = new Uint8Array(chunk.byteLength); + packet.set(new Uint8Array(chunk), 0); + + await characteristic.writeValue(packet); + + const progress = Math.round(((i + 1) / totalChunks) * 100); + writeAvatarButton.textContent = `In progress ... ${progress}%`; + + await new Promise(resolve => setTimeout(resolve, 50)); + } + + // reset the button + writeAvatarButton.disabled = false; + writeAvatarButton.textContent = 'Set Avatar'; + + showNotification('Success', `Avatar uploaded successfully! Sent ${totalChunks} packets, total size ${Math.round(totalSize / 1024)}KB`); + + } catch (error) { + console.error('Avatar error: ', error); + + // reset the button + writeAvatarButton.disabled = false; + writeAvatarButton.textContent = 'Set Avatar'; + + showNotification('Error', 'Avatar error: ' + error.message, true); + } + } + connectButton.addEventListener('click', async () => { if (!isConnected) { await connectToDevice(); @@ -722,6 +842,19 @@

⚠️ Device Reset Required

showNotification('Message', 'Background image cleared'); }); + writeAvatarButton.addEventListener('click', () => { + writeAvatarImage(); + }); + + clearAvatarButton.addEventListener('click', () => { + avatarPreview.style.backgroundImage = ''; + avatarPreview.classList.add('hidden'); + avatarImage.value = ''; + selectedAvatarFile = null; + writeAvatarButton.disabled = true; + showNotification('Message', 'Avatar cleared'); + }); + // Read AFE Linear Gain (string format f32) async function readAfeLinearGain() { if (!isConnected || !service) return false; diff --git a/setup/index_zh.html b/setup/index_zh.html index 8b4e199..c6f1b99 100644 --- a/setup/index_zh.html +++ b/setup/index_zh.html @@ -42,114 +42,141 @@

Wi-Fi(必须是2.4GHz)

网络名 (SSID)
- +
- + -
-
-

服务器URL设置

-