Desmond-Dong commited on
Commit
99f80f8
·
1 Parent(s): f493f8c

v0.5.15: Audio settings persistence, Sendspin discovery refactor, tap detection fix

Browse files
PROJECT_PLAN.md CHANGED
@@ -1150,6 +1150,50 @@ def _optimize_microphone_settings(self) -> None:
1150
 
1151
  **Fix**: Prioritize 16kHz in Sendspin supported formats list to avoid unnecessary resampling
1152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1153
 
1154
  ---
1155
 
 
1150
 
1151
  **Fix**: Prioritize 16kHz in Sendspin supported formats list to avoid unnecessary resampling
1152
 
1153
+ ---
1154
+
1155
+ ## 🔧 v0.5.15 Updates (2026-01-11)
1156
+
1157
+ ### Feature 1: Audio Settings Persistence
1158
+
1159
+ **Problem**: AGC Enabled, AGC Max Gain, Noise Suppression settings lost after restart.
1160
+
1161
+ **Solution**:
1162
+ - `models.py`: Added `agc_enabled`, `agc_max_gain`, `noise_suppression` fields to `Preferences` dataclass (Optional, None = use default)
1163
+ - `entity_registry.py`: Entity setters now save to `preferences.json`
1164
+ - `voice_assistant.py`: `_optimize_microphone_settings()` now restores saved values from preferences on startup
1165
+
1166
+ **Behavior**:
1167
+ - First startup: Use optimized defaults (AGC=ON, MaxGain=30dB, NoiseSuppression=15%)
1168
+ - After user changes via Home Assistant: Values persisted and restored on restart
1169
+
1170
+ ### Feature 2: Sendspin Discovery Refactoring
1171
+
1172
+ **Problem**: Sendspin mDNS discovery code was in `audio_player.py`, mixing concerns.
1173
+
1174
+ **Solution**:
1175
+ - `zeroconf.py`: Added `SendspinDiscovery` class for mDNS service discovery
1176
+ - `audio_player.py`: Simplified to use `SendspinDiscovery` via callback pattern
1177
+ - Better separation of concerns: zeroconf.py handles all mDNS, audio_player.py handles audio
1178
+
1179
+ ### Fix 1: Tap Detection During Emotion Playback
1180
+
1181
+ **Problem**: Tap detection was re-enabled after emotion playback completes, even during active conversation.
1182
+
1183
+ **Root Cause**: `_play_emotion()` and `_wait_for_move_completion()` always re-enabled tap detection without checking pipeline state.
1184
+
1185
+ **Fix**:
1186
+ - `satellite.py`: Check `_pipeline_active` before re-enabling tap detection
1187
+ - Only re-enable tap detection if conversation has ended (pipeline not active)
1188
+
1189
+ **Related Files**:
1190
+ - `models.py` - Preferences fields
1191
+ - `entity_registry.py` - Entity setters with persistence
1192
+ - `voice_assistant.py` - Settings restoration on startup
1193
+ - `zeroconf.py` - SendspinDiscovery class
1194
+ - `audio_player.py` - Simplified Sendspin integration
1195
+ - `satellite.py` - Tap detection fix
1196
+
1197
 
1198
  ---
1199
 
index.html CHANGED
@@ -123,6 +123,15 @@
123
  <h2>Changelog</h2>
124
  </div>
125
  <div class="changelog-grid">
 
 
 
 
 
 
 
 
 
126
  <div class="changelog-card">
127
  <div class="version-badge">v0.5.14</div>
128
  <span class="date">2026-01-11</span>
 
123
  <h2>Changelog</h2>
124
  </div>
125
  <div class="changelog-grid">
126
+ <div class="changelog-card">
127
+ <div class="version-badge">v0.5.15</div>
128
+ <span class="date">2026-01-11</span>
129
+ <ul>
130
+ <li>New: Audio settings persistence (AGC, Noise Suppression, Tap Sensitivity)</li>
131
+ <li>Refactor: Move Sendspin mDNS discovery to zeroconf.py for better code organization</li>
132
+ <li>Fix: Tap detection not re-enabled during emotion playback in conversation</li>
133
+ </ul>
134
+ </div>
135
  <div class="changelog-card">
136
  <div class="version-badge">v0.5.14</div>
137
  <span class="date">2026-01-11</span>
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "reachy_mini_ha_voice"
7
- version = "0.5.14"
8
  description = "Home Assistant Voice Assistant for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
4
 
5
  [project]
6
  name = "reachy_mini_ha_voice"
7
+ version = "0.5.15"
8
  description = "Home Assistant Voice Assistant for Reachy Mini"
9
  readme = "README.md"
10
  requires-python = ">=3.10"
reachy_mini_ha_voice/__init__.py CHANGED
@@ -11,7 +11,7 @@ Key features:
11
  - Reachy Mini motion control integration
12
  """
13
 
14
- __version__ = "0.5.14"
15
  __author__ = "Desmond Dong"
16
 
17
  # Don't import main module here to avoid runpy warning
 
11
  - Reachy Mini motion control integration
12
  """
13
 
14
+ __version__ = "0.5.15"
15
  __author__ = "Desmond Dong"
16
 
17
  # Don't import main module here to avoid runpy warning
reachy_mini_ha_voice/audio_player.py CHANGED
@@ -31,19 +31,6 @@ except ImportError:
31
  SENDSPIN_AVAILABLE = False
32
  _LOGGER.debug("aiosendspin not installed, Sendspin support disabled")
33
 
34
- # Check if zeroconf is available for mDNS discovery
35
- try:
36
- from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf
37
- ZEROCONF_AVAILABLE = True
38
- except ImportError:
39
- ZEROCONF_AVAILABLE = False
40
- _LOGGER.debug("zeroconf not installed, Sendspin auto-discovery disabled")
41
-
42
-
43
- # Sendspin mDNS service type
44
- SENDSPIN_SERVICE_TYPE = "_sendspin-server._tcp.local."
45
- SENDSPIN_DEFAULT_PATH = "/sendspin"
46
-
47
 
48
  def _get_stable_client_id() -> str:
49
  """Generate a stable client ID based on machine identity.
@@ -93,10 +80,7 @@ class AudioPlayer:
93
  self._sendspin_client: Optional["SendspinClient"] = None
94
  self._sendspin_enabled = False
95
  self._sendspin_url: Optional[str] = None
96
- self._sendspin_loop: Optional[asyncio.AbstractEventLoop] = None
97
- self._sendspin_discovery_task: Optional[asyncio.Task] = None
98
- self._sendspin_zeroconf: Optional["AsyncZeroconf"] = None
99
- self._sendspin_browser: Optional["AsyncServiceBrowser"] = None
100
  self._sendspin_unsubscribers: List[Callable] = []
101
 
102
  # Audio buffer for Sendspin playback
@@ -113,7 +97,7 @@ class AudioPlayer:
113
  @property
114
  def sendspin_available(self) -> bool:
115
  """Check if Sendspin library is available."""
116
- return SENDSPIN_AVAILABLE and ZEROCONF_AVAILABLE
117
 
118
  @property
119
  def sendspin_enabled(self) -> bool:
@@ -157,53 +141,24 @@ class AudioPlayer:
157
  _LOGGER.debug("aiosendspin not installed, skipping Sendspin discovery")
158
  return
159
 
160
- if not ZEROCONF_AVAILABLE:
161
- _LOGGER.debug("zeroconf not installed, skipping Sendspin discovery")
162
- return
163
-
164
- if self._sendspin_discovery_task is not None:
165
  _LOGGER.debug("Sendspin discovery already running")
166
  return
167
 
 
 
 
168
  _LOGGER.info("Starting Sendspin server discovery...")
169
- self._sendspin_loop = asyncio.get_running_loop()
170
- self._sendspin_discovery_task = asyncio.create_task(self._discover_and_connect())
171
 
172
- async def _discover_and_connect(self) -> None:
173
- """Background task to discover and connect to Sendspin servers."""
174
- try:
175
- self._sendspin_zeroconf = AsyncZeroconf()
176
- await self._sendspin_zeroconf.__aenter__()
177
-
178
- # Create a listener for Sendspin services
179
- listener = _SendspinServiceListener(self)
180
- self._sendspin_browser = AsyncServiceBrowser(
181
- self._sendspin_zeroconf.zeroconf,
182
- SENDSPIN_SERVICE_TYPE,
183
- listener,
184
- )
185
-
186
- _LOGGER.info("Sendspin discovery started, waiting for servers...")
187
-
188
- # Keep running until stopped
189
- while True:
190
- await asyncio.sleep(60) # Check periodically
191
-
192
- except asyncio.CancelledError:
193
- _LOGGER.debug("Sendspin discovery cancelled")
194
- except Exception as e:
195
- _LOGGER.error("Sendspin discovery error: %s", e)
196
- finally:
197
- await self._cleanup_discovery()
198
-
199
- async def _cleanup_discovery(self) -> None:
200
- """Clean up discovery resources."""
201
- if self._sendspin_browser:
202
- await self._sendspin_browser.async_cancel()
203
- self._sendspin_browser = None
204
- if self._sendspin_zeroconf:
205
- await self._sendspin_zeroconf.__aexit__(None, None, None)
206
- self._sendspin_zeroconf = None
207
 
208
  async def _connect_to_server(self, server_url: str) -> bool:
209
  """Connect to a discovered Sendspin server as PLAYER.
@@ -408,18 +363,13 @@ class AudioPlayer:
408
 
409
  async def stop_sendspin(self) -> None:
410
  """Stop Sendspin discovery and disconnect from server."""
411
- # Cancel discovery task
412
- if self._sendspin_discovery_task is not None:
413
- self._sendspin_discovery_task.cancel()
414
- try:
415
- await self._sendspin_discovery_task
416
- except asyncio.CancelledError:
417
- pass
418
- self._sendspin_discovery_task = None
419
 
420
  # Disconnect from server
421
  await self._disconnect_sendspin()
422
- self._sendspin_loop = None
423
 
424
  _LOGGER.info("Sendspin stopped")
425
 
@@ -587,68 +537,3 @@ class AudioPlayer:
587
  self._unduck_volume = volume / 100.0
588
  self._duck_volume = self._unduck_volume / 2
589
  self._current_volume = self._unduck_volume
590
-
591
-
592
- # ========== Sendspin mDNS Service Listener ==========
593
-
594
- class _SendspinServiceListener:
595
- """Listener for Sendspin server mDNS advertisements."""
596
-
597
- def __init__(self, audio_player: AudioPlayer) -> None:
598
- self._audio_player = audio_player
599
- self._loop = audio_player._sendspin_loop
600
-
601
- def _build_url(self, host: str, port: int, properties: dict) -> str:
602
- """Build WebSocket URL from service info."""
603
- path_raw = properties.get(b"path")
604
- path = path_raw.decode("utf-8", "ignore") if isinstance(path_raw, bytes) else SENDSPIN_DEFAULT_PATH
605
- if not path:
606
- path = SENDSPIN_DEFAULT_PATH
607
- if not path.startswith("/"):
608
- path = "/" + path
609
- host_fmt = f"[{host}]" if ":" in host else host
610
- return f"ws://{host_fmt}:{port}{path}"
611
-
612
- def add_service(self, zeroconf, service_type: str, name: str) -> None:
613
- """Called when a Sendspin server is discovered."""
614
- if self._loop is None:
615
- return
616
- asyncio.run_coroutine_threadsafe(
617
- self._process_service(zeroconf, service_type, name),
618
- self._loop,
619
- )
620
-
621
- def update_service(self, zeroconf, service_type: str, name: str) -> None:
622
- """Called when a Sendspin server is updated."""
623
- self.add_service(zeroconf, service_type, name)
624
-
625
- def remove_service(self, zeroconf, service_type: str, name: str) -> None:
626
- """Called when a Sendspin server goes offline."""
627
- _LOGGER.info("Sendspin server removed: %s", name)
628
-
629
- async def _process_service(self, zeroconf, service_type: str, name: str) -> None:
630
- """Process discovered service and connect."""
631
- try:
632
- from zeroconf.asyncio import AsyncZeroconf
633
-
634
- # Get service info
635
- azc = AsyncZeroconf(zc=zeroconf)
636
- info = await azc.async_get_service_info(service_type, name)
637
-
638
- if info is None or info.port is None:
639
- return
640
-
641
- addresses = info.parsed_addresses()
642
- if not addresses:
643
- return
644
-
645
- host = addresses[0]
646
- url = self._build_url(host, info.port, info.properties)
647
-
648
- _LOGGER.info("Discovered Sendspin server: %s at %s", name, url)
649
-
650
- # Auto-connect to the server
651
- await self._audio_player._connect_to_server(url)
652
-
653
- except Exception as e:
654
- _LOGGER.warning("Error processing Sendspin service %s: %s", name, e)
 
31
  SENDSPIN_AVAILABLE = False
32
  _LOGGER.debug("aiosendspin not installed, Sendspin support disabled")
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  def _get_stable_client_id() -> str:
36
  """Generate a stable client ID based on machine identity.
 
80
  self._sendspin_client: Optional["SendspinClient"] = None
81
  self._sendspin_enabled = False
82
  self._sendspin_url: Optional[str] = None
83
+ self._sendspin_discovery: Optional["SendspinDiscovery"] = None
 
 
 
84
  self._sendspin_unsubscribers: List[Callable] = []
85
 
86
  # Audio buffer for Sendspin playback
 
97
  @property
98
  def sendspin_available(self) -> bool:
99
  """Check if Sendspin library is available."""
100
+ return SENDSPIN_AVAILABLE
101
 
102
  @property
103
  def sendspin_enabled(self) -> bool:
 
141
  _LOGGER.debug("aiosendspin not installed, skipping Sendspin discovery")
142
  return
143
 
144
+ if self._sendspin_discovery is not None and self._sendspin_discovery.is_running:
 
 
 
 
145
  _LOGGER.debug("Sendspin discovery already running")
146
  return
147
 
148
+ # Import here to avoid circular imports
149
+ from .zeroconf import SendspinDiscovery
150
+
151
  _LOGGER.info("Starting Sendspin server discovery...")
152
+ self._sendspin_discovery = SendspinDiscovery(self._on_sendspin_server_found)
153
+ await self._sendspin_discovery.start()
154
 
155
+ async def _on_sendspin_server_found(self, server_url: str) -> None:
156
+ """Callback when a Sendspin server is discovered via mDNS.
157
+
158
+ Args:
159
+ server_url: WebSocket URL of the discovered server.
160
+ """
161
+ await self._connect_to_server(server_url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  async def _connect_to_server(self, server_url: str) -> bool:
164
  """Connect to a discovered Sendspin server as PLAYER.
 
363
 
364
  async def stop_sendspin(self) -> None:
365
  """Stop Sendspin discovery and disconnect from server."""
366
+ # Stop discovery
367
+ if self._sendspin_discovery is not None:
368
+ await self._sendspin_discovery.stop()
369
+ self._sendspin_discovery = None
 
 
 
 
370
 
371
  # Disconnect from server
372
  await self._disconnect_sendspin()
 
373
 
374
  _LOGGER.info("Sendspin stopped")
375
 
 
537
  self._unduck_volume = volume / 100.0
538
  self._duck_volume = self._unduck_volume / 2
539
  self._current_volume = self._unduck_volume
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reachy_mini_ha_voice/entity_registry.py CHANGED
@@ -763,6 +763,30 @@ class EntityRegistry:
763
  """Setup Phase 12 entities: Audio processing parameters (via local SDK)."""
764
  rc = self.reachy_controller
765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  entities.append(SwitchEntity(
767
  server=self.server,
768
  key=get_entity_key("agc_enabled"),
@@ -772,7 +796,7 @@ class EntityRegistry:
772
  device_class="switch",
773
  entity_category=1, # config
774
  value_getter=rc.get_agc_enabled,
775
- value_setter=rc.set_agc_enabled,
776
  ))
777
 
778
  entities.append(NumberEntity(
@@ -788,7 +812,7 @@ class EntityRegistry:
788
  mode=2,
789
  entity_category=1, # config
790
  value_getter=rc.get_agc_max_gain,
791
- value_setter=rc.set_agc_max_gain,
792
  ))
793
 
794
  entities.append(NumberEntity(
@@ -804,7 +828,7 @@ class EntityRegistry:
804
  mode=2,
805
  entity_category=1, # config
806
  value_getter=rc.get_noise_suppression,
807
- value_setter=rc.set_noise_suppression,
808
  ))
809
 
810
  entities.append(BinarySensorEntity(
 
763
  """Setup Phase 12 entities: Audio processing parameters (via local SDK)."""
764
  rc = self.reachy_controller
765
 
766
+ def set_agc_enabled_with_save(enabled: bool) -> None:
767
+ """Set AGC enabled and save to preferences."""
768
+ rc.set_agc_enabled(enabled)
769
+ if hasattr(self.server, 'state') and self.server.state:
770
+ self.server.state.preferences.agc_enabled = enabled
771
+ self.server.state.save_preferences()
772
+ _LOGGER.debug("AGC enabled saved to preferences: %s", enabled)
773
+
774
+ def set_agc_max_gain_with_save(gain: float) -> None:
775
+ """Set AGC max gain and save to preferences."""
776
+ rc.set_agc_max_gain(gain)
777
+ if hasattr(self.server, 'state') and self.server.state:
778
+ self.server.state.preferences.agc_max_gain = gain
779
+ self.server.state.save_preferences()
780
+ _LOGGER.debug("AGC max gain saved to preferences: %.1f dB", gain)
781
+
782
+ def set_noise_suppression_with_save(level: float) -> None:
783
+ """Set noise suppression and save to preferences."""
784
+ rc.set_noise_suppression(level)
785
+ if hasattr(self.server, 'state') and self.server.state:
786
+ self.server.state.preferences.noise_suppression = level
787
+ self.server.state.save_preferences()
788
+ _LOGGER.debug("Noise suppression saved to preferences: %.1f%%", level)
789
+
790
  entities.append(SwitchEntity(
791
  server=self.server,
792
  key=get_entity_key("agc_enabled"),
 
796
  device_class="switch",
797
  entity_category=1, # config
798
  value_getter=rc.get_agc_enabled,
799
+ value_setter=set_agc_enabled_with_save,
800
  ))
801
 
802
  entities.append(NumberEntity(
 
812
  mode=2,
813
  entity_category=1, # config
814
  value_getter=rc.get_agc_max_gain,
815
+ value_setter=set_agc_max_gain_with_save,
816
  ))
817
 
818
  entities.append(NumberEntity(
 
828
  mode=2,
829
  entity_category=1, # config
830
  value_getter=rc.get_noise_suppression,
831
+ value_setter=set_noise_suppression_with_save,
832
  ))
833
 
834
  entities.append(BinarySensorEntity(
reachy_mini_ha_voice/models.py CHANGED
@@ -49,6 +49,10 @@ class AvailableWakeWord:
49
  class Preferences:
50
  active_wake_words: List[str] = field(default_factory=list)
51
  tap_sensitivity: float = 0.5 # Tap detection threshold in g (0.5 = most sensitive)
 
 
 
 
52
 
53
 
54
  @dataclass
 
49
  class Preferences:
50
  active_wake_words: List[str] = field(default_factory=list)
51
  tap_sensitivity: float = 0.5 # Tap detection threshold in g (0.5 = most sensitive)
52
+ # Audio processing settings (persisted from Home Assistant)
53
+ agc_enabled: Optional[bool] = None # None = use hardware default
54
+ agc_max_gain: Optional[float] = None # None = use hardware default
55
+ noise_suppression: Optional[float] = None # None = use hardware default
56
 
57
 
58
  @dataclass
reachy_mini_ha_voice/satellite.py CHANGED
@@ -918,24 +918,26 @@ class VoiceSatelliteProtocol(APIServer):
918
  self._wait_for_move_completion(wlan_ip, move_uuid, tap_detector)
919
  threading.Thread(target=wait_for_completion, daemon=True).start()
920
  elif tap_detector:
921
- # Fallback: re-enable after 5 seconds if no UUID
922
  def reenable_tap_fallback():
923
  time.sleep(5.0)
924
- if tap_detector:
925
  tap_detector.set_enabled(True)
926
  _LOGGER.debug("Tap detection re-enabled (fallback timeout)")
 
 
927
  threading.Thread(target=reenable_tap_fallback, daemon=True).start()
928
  else:
929
  _LOGGER.warning(f"Failed to play emotion {emotion_name}: HTTP {response.status_code}")
930
- # Re-enable tap detection on failure
931
- if tap_detector:
932
  tap_detector.set_enabled(True)
933
 
934
  except Exception as e:
935
  _LOGGER.error(f"Error playing emotion {emotion_name}: {e}")
936
- # Re-enable tap detection on error
937
  tap_detector = getattr(self.state, 'tap_detector', None)
938
- if tap_detector:
939
  tap_detector.set_enabled(True)
940
 
941
  def _wait_for_move_completion(self, wlan_ip: str, move_uuid: str, tap_detector) -> None:
@@ -974,7 +976,10 @@ class VoiceSatelliteProtocol(APIServer):
974
  else:
975
  _LOGGER.warning(f"Move {move_uuid} did not complete within {MAX_WAIT_SECONDS}s")
976
  finally:
977
- # Always re-enable tap detection
978
- if tap_detector:
 
979
  tap_detector.set_enabled(True)
980
  _LOGGER.debug("Tap detection re-enabled after move completion")
 
 
 
918
  self._wait_for_move_completion(wlan_ip, move_uuid, tap_detector)
919
  threading.Thread(target=wait_for_completion, daemon=True).start()
920
  elif tap_detector:
921
+ # Fallback: re-enable after 5 seconds if no UUID (only if pipeline not active)
922
  def reenable_tap_fallback():
923
  time.sleep(5.0)
924
+ if tap_detector and not self._pipeline_active:
925
  tap_detector.set_enabled(True)
926
  _LOGGER.debug("Tap detection re-enabled (fallback timeout)")
927
+ elif self._pipeline_active:
928
+ _LOGGER.debug("Pipeline active, tap detection stays disabled (fallback)")
929
  threading.Thread(target=reenable_tap_fallback, daemon=True).start()
930
  else:
931
  _LOGGER.warning(f"Failed to play emotion {emotion_name}: HTTP {response.status_code}")
932
+ # Re-enable tap detection on failure (only if pipeline not active)
933
+ if tap_detector and not self._pipeline_active:
934
  tap_detector.set_enabled(True)
935
 
936
  except Exception as e:
937
  _LOGGER.error(f"Error playing emotion {emotion_name}: {e}")
938
+ # Re-enable tap detection on error (only if pipeline not active)
939
  tap_detector = getattr(self.state, 'tap_detector', None)
940
+ if tap_detector and not self._pipeline_active:
941
  tap_detector.set_enabled(True)
942
 
943
  def _wait_for_move_completion(self, wlan_ip: str, move_uuid: str, tap_detector) -> None:
 
976
  else:
977
  _LOGGER.warning(f"Move {move_uuid} did not complete within {MAX_WAIT_SECONDS}s")
978
  finally:
979
+ # Only re-enable tap detection if pipeline is NOT active
980
+ # This prevents re-enabling during conversation when emotion was triggered
981
+ if tap_detector and not self._pipeline_active:
982
  tap_detector.set_enabled(True)
983
  _LOGGER.debug("Tap detection re-enabled after move completion")
984
+ elif self._pipeline_active:
985
+ _LOGGER.debug("Pipeline active, tap detection stays disabled after move")
reachy_mini_ha_voice/voice_assistant.py CHANGED
@@ -236,6 +236,9 @@ class VoiceAssistantService:
236
  This method configures the XMOS XVF3800 audio processor for optimal
237
  voice command recognition at distances up to 2-3 meters.
238
 
 
 
 
239
  Key optimizations:
240
  1. Enable AGC with higher max gain for distant speech
241
  2. Reduce noise suppression to preserve quiet speech
@@ -260,20 +263,28 @@ class VoiceAssistantService:
260
  _LOGGER.debug("ReSpeaker device not found")
261
  return
262
 
 
 
 
263
  # ========== 1. AGC (Automatic Gain Control) Settings ==========
264
- # Enable AGC for automatic volume normalization
 
265
  try:
266
- respeaker.write("PP_AGCONOFF", [1])
267
- _LOGGER.info("AGC enabled (PP_AGCONOFF=1)")
 
 
 
268
  except Exception as e:
269
- _LOGGER.debug("Could not enable AGC: %s", e)
270
 
271
- # Increase AGC max gain for better distant speech pickup
272
- # Default is ~15dB, increase to 30dB for voice commands at distance
273
- # Range: 0-40 dB (float)
274
  try:
275
- respeaker.write("PP_AGCMAXGAIN", [30.0])
276
- _LOGGER.info("AGC max gain increased (PP_AGCMAXGAIN=30.0dB)")
 
 
277
  except Exception as e:
278
  _LOGGER.debug("Could not set PP_AGCMAXGAIN: %s", e)
279
 
@@ -282,7 +293,7 @@ class VoiceAssistantService:
282
  # Default is around -25dB, set to -18dB for stronger output
283
  try:
284
  respeaker.write("PP_AGCDESIREDLEVEL", [-18.0])
285
- _LOGGER.info("AGC desired level set (PP_AGCDESIREDLEVEL=-18.0dB)")
286
  except Exception as e:
287
  _LOGGER.debug("Could not set PP_AGCDESIREDLEVEL: %s", e)
288
 
@@ -305,22 +316,26 @@ class VoiceAssistantService:
305
  _LOGGER.debug("Could not set AUDIO_MGR_MIC_GAIN: %s", e)
306
 
307
  # ========== 3. Noise Suppression Settings ==========
308
- # Reduce noise suppression to preserve quiet speech
309
  # PP_MIN_NS: minimum noise suppression threshold
310
  # Higher values = less aggressive suppression = better voice pickup
311
  # PP_MIN_NS = 0.85 means "keep at least 85% of signal" = 15% max suppression
312
  # UI shows "noise suppression strength" so 15% = PP_MIN_NS of 0.85
 
 
313
  try:
314
- respeaker.write("PP_MIN_NS", [0.85]) # 15% noise suppression strength
315
- _LOGGER.info("Noise suppression set to 15%% strength (PP_MIN_NS=0.85)")
 
 
316
  except Exception as e:
317
  _LOGGER.debug("Could not set PP_MIN_NS: %s", e)
318
 
319
  # PP_MIN_NN: minimum noise floor estimation
320
  # Higher values = less aggressive noise floor tracking
321
  try:
322
- respeaker.write("PP_MIN_NN", [0.85]) # Match PP_MIN_NS
323
- _LOGGER.info("Noise floor threshold set (PP_MIN_NN=0.85)")
324
  except Exception as e:
325
  _LOGGER.debug("Could not set PP_MIN_NN: %s", e)
326
 
@@ -339,7 +354,8 @@ class VoiceAssistantService:
339
  except Exception as e:
340
  _LOGGER.debug("Could not set AEC_HPFONOFF: %s", e)
341
 
342
- _LOGGER.info("Microphone settings optimized for voice recognition (AGC=ON, MaxGain=30dB, MicGain=2.0x)")
 
343
 
344
  except Exception as e:
345
  _LOGGER.warning("Failed to optimize microphone settings: %s", e)
 
236
  This method configures the XMOS XVF3800 audio processor for optimal
237
  voice command recognition at distances up to 2-3 meters.
238
 
239
+ If user has previously set values via Home Assistant, those values are
240
+ restored from preferences. Otherwise, default optimized values are used.
241
+
242
  Key optimizations:
243
  1. Enable AGC with higher max gain for distant speech
244
  2. Reduce noise suppression to preserve quiet speech
 
263
  _LOGGER.debug("ReSpeaker device not found")
264
  return
265
 
266
+ # Get saved preferences (if any)
267
+ prefs = self._state.preferences if self._state else None
268
+
269
  # ========== 1. AGC (Automatic Gain Control) Settings ==========
270
+ # Use saved value if available, otherwise use default (enabled)
271
+ agc_enabled = prefs.agc_enabled if (prefs and prefs.agc_enabled is not None) else True
272
  try:
273
+ respeaker.write("PP_AGCONOFF", [1 if agc_enabled else 0])
274
+ _LOGGER.info("AGC %s (PP_AGCONOFF=%d)%s",
275
+ "enabled" if agc_enabled else "disabled",
276
+ 1 if agc_enabled else 0,
277
+ " [from preferences]" if (prefs and prefs.agc_enabled is not None) else " [default]")
278
  except Exception as e:
279
+ _LOGGER.debug("Could not set AGC: %s", e)
280
 
281
+ # Use saved value if available, otherwise use default (30dB)
282
+ agc_max_gain = prefs.agc_max_gain if (prefs and prefs.agc_max_gain is not None) else 30.0
 
283
  try:
284
+ respeaker.write("PP_AGCMAXGAIN", [agc_max_gain])
285
+ _LOGGER.info("AGC max gain set (PP_AGCMAXGAIN=%.1fdB)%s",
286
+ agc_max_gain,
287
+ " [from preferences]" if (prefs and prefs.agc_max_gain is not None) else " [default]")
288
  except Exception as e:
289
  _LOGGER.debug("Could not set PP_AGCMAXGAIN: %s", e)
290
 
 
293
  # Default is around -25dB, set to -18dB for stronger output
294
  try:
295
  respeaker.write("PP_AGCDESIREDLEVEL", [-18.0])
296
+ _LOGGER.debug("AGC desired level set (PP_AGCDESIREDLEVEL=-18.0dB)")
297
  except Exception as e:
298
  _LOGGER.debug("Could not set PP_AGCDESIREDLEVEL: %s", e)
299
 
 
316
  _LOGGER.debug("Could not set AUDIO_MGR_MIC_GAIN: %s", e)
317
 
318
  # ========== 3. Noise Suppression Settings ==========
319
+ # Use saved value if available, otherwise use default (15%)
320
  # PP_MIN_NS: minimum noise suppression threshold
321
  # Higher values = less aggressive suppression = better voice pickup
322
  # PP_MIN_NS = 0.85 means "keep at least 85% of signal" = 15% max suppression
323
  # UI shows "noise suppression strength" so 15% = PP_MIN_NS of 0.85
324
+ noise_suppression = prefs.noise_suppression if (prefs and prefs.noise_suppression is not None) else 15.0
325
+ pp_min_ns = 1.0 - (noise_suppression / 100.0) # Convert percentage to PP_MIN_NS value
326
  try:
327
+ respeaker.write("PP_MIN_NS", [pp_min_ns])
328
+ _LOGGER.info("Noise suppression set to %.0f%% strength (PP_MIN_NS=%.2f)%s",
329
+ noise_suppression, pp_min_ns,
330
+ " [from preferences]" if (prefs and prefs.noise_suppression is not None) else " [default]")
331
  except Exception as e:
332
  _LOGGER.debug("Could not set PP_MIN_NS: %s", e)
333
 
334
  # PP_MIN_NN: minimum noise floor estimation
335
  # Higher values = less aggressive noise floor tracking
336
  try:
337
+ respeaker.write("PP_MIN_NN", [pp_min_ns]) # Match PP_MIN_NS
338
+ _LOGGER.debug("Noise floor threshold set (PP_MIN_NN=%.2f)", pp_min_ns)
339
  except Exception as e:
340
  _LOGGER.debug("Could not set PP_MIN_NN: %s", e)
341
 
 
354
  except Exception as e:
355
  _LOGGER.debug("Could not set AEC_HPFONOFF: %s", e)
356
 
357
+ _LOGGER.info("Microphone settings initialized (AGC=%s, MaxGain=%.0fdB, NoiseSuppression=%.0f%%)",
358
+ "ON" if agc_enabled else "OFF", agc_max_gain, noise_suppression)
359
 
360
  except Exception as e:
361
  _LOGGER.warning("Failed to optimize microphone settings: %s", e)
reachy_mini_ha_voice/zeroconf.py CHANGED
@@ -1,21 +1,43 @@
1
- """Runs mDNS zeroconf service for Home Assistant discovery."""
2
 
 
3
  import logging
4
  import socket
5
- from typing import Optional
6
 
7
  from .util import get_mac
8
 
 
 
 
9
  _LOGGER = logging.getLogger(__name__)
10
 
11
  try:
12
- from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
 
13
  except ImportError:
14
  _LOGGER.fatal("pip install zeroconf")
15
  raise
16
 
17
  MDNS_TARGET_IP = "224.0.0.251"
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class HomeAssistantZeroconf:
21
  """Zeroconf service for Home Assistant discovery."""
@@ -27,15 +49,7 @@ class HomeAssistantZeroconf:
27
  self.name = name or f"reachy-mini-{get_mac()[:6]}"
28
 
29
  if not host:
30
- test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
31
- test_sock.setblocking(False)
32
- try:
33
- test_sock.connect((MDNS_TARGET_IP, 1))
34
- host = test_sock.getsockname()[0]
35
- except Exception:
36
- host = "127.0.0.1"
37
- finally:
38
- test_sock.close()
39
  _LOGGER.debug("Detected IP: %s", host)
40
 
41
  assert host
@@ -64,3 +78,157 @@ class HomeAssistantZeroconf:
64
 
65
  async def unregister_server(self) -> None:
66
  await self._aiozc.async_close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runs mDNS zeroconf services for Home Assistant and Sendspin discovery."""
2
 
3
+ import asyncio
4
  import logging
5
  import socket
6
+ from typing import Callable, Optional, TYPE_CHECKING
7
 
8
  from .util import get_mac
9
 
10
+ if TYPE_CHECKING:
11
+ from zeroconf.asyncio import AsyncServiceBrowser
12
+
13
  _LOGGER = logging.getLogger(__name__)
14
 
15
  try:
16
+ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf, AsyncServiceBrowser
17
+ ZEROCONF_AVAILABLE = True
18
  except ImportError:
19
  _LOGGER.fatal("pip install zeroconf")
20
  raise
21
 
22
  MDNS_TARGET_IP = "224.0.0.251"
23
 
24
+ # Sendspin mDNS service type
25
+ SENDSPIN_SERVICE_TYPE = "_sendspin-server._tcp.local."
26
+ SENDSPIN_DEFAULT_PATH = "/sendspin"
27
+
28
+
29
+ def get_local_ip() -> str:
30
+ """Get local IP address for mDNS."""
31
+ test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
32
+ test_sock.setblocking(False)
33
+ try:
34
+ test_sock.connect((MDNS_TARGET_IP, 1))
35
+ return test_sock.getsockname()[0]
36
+ except Exception:
37
+ return "127.0.0.1"
38
+ finally:
39
+ test_sock.close()
40
+
41
 
42
  class HomeAssistantZeroconf:
43
  """Zeroconf service for Home Assistant discovery."""
 
49
  self.name = name or f"reachy-mini-{get_mac()[:6]}"
50
 
51
  if not host:
52
+ host = get_local_ip()
 
 
 
 
 
 
 
 
53
  _LOGGER.debug("Detected IP: %s", host)
54
 
55
  assert host
 
78
 
79
  async def unregister_server(self) -> None:
80
  await self._aiozc.async_close()
81
+
82
+
83
+ class SendspinDiscovery:
84
+ """mDNS discovery for Sendspin servers.
85
+
86
+ Discovers Sendspin servers on the local network and notifies via callback
87
+ when a server is found.
88
+ """
89
+
90
+ def __init__(self, on_server_found: Callable[[str], asyncio.coroutine]) -> None:
91
+ """Initialize Sendspin discovery.
92
+
93
+ Args:
94
+ on_server_found: Async callback called with server URL when discovered.
95
+ """
96
+ self._on_server_found = on_server_found
97
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
98
+ self._zeroconf: Optional[AsyncZeroconf] = None
99
+ self._browser: Optional["AsyncServiceBrowser"] = None
100
+ self._discovery_task: Optional[asyncio.Task] = None
101
+ self._running = False
102
+
103
+ @property
104
+ def is_running(self) -> bool:
105
+ """Check if discovery is running."""
106
+ return self._running
107
+
108
+ async def start(self) -> None:
109
+ """Start mDNS discovery for Sendspin servers."""
110
+ if self._running:
111
+ _LOGGER.debug("Sendspin discovery already running")
112
+ return
113
+
114
+ _LOGGER.info("Starting Sendspin server discovery...")
115
+ self._loop = asyncio.get_running_loop()
116
+ self._running = True
117
+ self._discovery_task = asyncio.create_task(self._discover_loop())
118
+
119
+ async def _discover_loop(self) -> None:
120
+ """Background task to discover Sendspin servers."""
121
+ try:
122
+ self._zeroconf = AsyncZeroconf()
123
+ await self._zeroconf.__aenter__()
124
+
125
+ listener = _SendspinServiceListener(self)
126
+ self._browser = AsyncServiceBrowser(
127
+ self._zeroconf.zeroconf,
128
+ SENDSPIN_SERVICE_TYPE,
129
+ listener,
130
+ )
131
+
132
+ _LOGGER.info("Sendspin discovery started, waiting for servers...")
133
+
134
+ # Keep running until stopped
135
+ while self._running:
136
+ await asyncio.sleep(60)
137
+
138
+ except asyncio.CancelledError:
139
+ _LOGGER.debug("Sendspin discovery cancelled")
140
+ except Exception as e:
141
+ _LOGGER.error("Sendspin discovery error: %s", e)
142
+ finally:
143
+ await self._cleanup()
144
+
145
+ async def _cleanup(self) -> None:
146
+ """Clean up discovery resources."""
147
+ if self._browser:
148
+ await self._browser.async_cancel()
149
+ self._browser = None
150
+ if self._zeroconf:
151
+ await self._zeroconf.__aexit__(None, None, None)
152
+ self._zeroconf = None
153
+ self._running = False
154
+
155
+ async def stop(self) -> None:
156
+ """Stop Sendspin discovery."""
157
+ self._running = False
158
+ if self._discovery_task is not None:
159
+ self._discovery_task.cancel()
160
+ try:
161
+ await self._discovery_task
162
+ except asyncio.CancelledError:
163
+ pass
164
+ self._discovery_task = None
165
+
166
+ await self._cleanup()
167
+ self._loop = None
168
+ _LOGGER.info("Sendspin discovery stopped")
169
+
170
+ async def _handle_service_found(self, url: str) -> None:
171
+ """Handle discovered service."""
172
+ try:
173
+ await self._on_server_found(url)
174
+ except Exception as e:
175
+ _LOGGER.error("Error in Sendspin server callback: %s", e)
176
+
177
+
178
+ class _SendspinServiceListener:
179
+ """Listener for Sendspin server mDNS advertisements."""
180
+
181
+ def __init__(self, discovery: SendspinDiscovery) -> None:
182
+ self._discovery = discovery
183
+
184
+ def _build_url(self, host: str, port: int, properties: dict) -> str:
185
+ """Build WebSocket URL from service info."""
186
+ path_raw = properties.get(b"path")
187
+ path = path_raw.decode("utf-8", "ignore") if isinstance(path_raw, bytes) else SENDSPIN_DEFAULT_PATH
188
+ if not path:
189
+ path = SENDSPIN_DEFAULT_PATH
190
+ if not path.startswith("/"):
191
+ path = "/" + path
192
+ host_fmt = f"[{host}]" if ":" in host else host
193
+ return f"ws://{host_fmt}:{port}{path}"
194
+
195
+ def add_service(self, zeroconf, service_type: str, name: str) -> None:
196
+ """Called when a Sendspin server is discovered."""
197
+ if self._discovery._loop is None:
198
+ return
199
+ asyncio.run_coroutine_threadsafe(
200
+ self._process_service(zeroconf, service_type, name),
201
+ self._discovery._loop,
202
+ )
203
+
204
+ def update_service(self, zeroconf, service_type: str, name: str) -> None:
205
+ """Called when a Sendspin server is updated."""
206
+ self.add_service(zeroconf, service_type, name)
207
+
208
+ def remove_service(self, zeroconf, service_type: str, name: str) -> None:
209
+ """Called when a Sendspin server goes offline."""
210
+ _LOGGER.info("Sendspin server removed: %s", name)
211
+
212
+ async def _process_service(self, zeroconf, service_type: str, name: str) -> None:
213
+ """Process discovered service and notify callback."""
214
+ try:
215
+ azc = AsyncZeroconf(zc=zeroconf)
216
+ info = await azc.async_get_service_info(service_type, name)
217
+
218
+ if info is None or info.port is None:
219
+ return
220
+
221
+ addresses = info.parsed_addresses()
222
+ if not addresses:
223
+ return
224
+
225
+ host = addresses[0]
226
+ url = self._build_url(host, info.port, info.properties)
227
+
228
+ _LOGGER.info("Discovered Sendspin server: %s at %s", name, url)
229
+
230
+ # Notify via callback
231
+ await self._discovery._handle_service_found(url)
232
+
233
+ except Exception as e:
234
+ _LOGGER.warning("Error processing Sendspin service %s: %s", name, e)