voice-vibecoder 2.12.3__tar.gz → 2.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/CHANGELOG.md +16 -0
  2. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/PKG-INFO +1 -1
  3. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/pyproject.toml +1 -1
  4. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/cli_serve.py +17 -5
  6. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/config.py +149 -38
  7. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/server.py +79 -16
  8. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/session.py +33 -7
  9. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/screen.py +3 -7
  10. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/setup.py +195 -56
  11. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/wizard.py +44 -14
  12. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/voice_providers/__init__.py +26 -2
  13. voice_vibecoder-2.14.0/src/voice_vibecoder/voice_providers/gemini.py +395 -0
  14. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/.github/workflows/ci.yml +0 -0
  15. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/.github/workflows/release.yml +0 -0
  16. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/.gitignore +0 -0
  17. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/LICENSE +0 -0
  18. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/README.md +0 -0
  19. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/app.py +0 -0
  20. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/index.html +0 -0
  21. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/app_config.py +0 -0
  22. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/audio.py +0 -0
  23. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  24. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  25. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  26. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/openclaw.py +0 -0
  27. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  28. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/instances.py +0 -0
  29. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  30. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  31. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  32. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  33. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  34. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  35. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  36. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  37. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  38. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  39. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/panels.py +0 -0
  40. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/state.py +0 -0
  41. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/styles.py +0 -0
  42. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  43. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  44. {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.14.0 (2026-02-21)
6
+
7
+
8
+ ## v2.13.0 (2026-02-21)
9
+
10
+ ### Features
11
+
12
+ - Add silence duration and prefix padding options
13
+ ([`0d7d89c`](https://github.com/snokam/voice-vibecoder/commit/0d7d89cc238cbe396b0fe30a0fbf666118e8faf5))
14
+
15
+ ### Refactoring
16
+
17
+ - **config**: Store voice and VAD settings per provider
18
+ ([`fca2e8b`](https://github.com/snokam/voice-vibecoder/commit/fca2e8bf20ee16bc66a29ffb0b0c1544d3c39c35))
19
+
20
+
5
21
  ## v2.12.3 (2026-02-21)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.12.3
3
+ Version: 2.14.0
4
4
  Summary: Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code
5
5
  Project-URL: Homepage, https://github.com/snokam/voice-vibecoder
6
6
  Project-URL: Repository, https://github.com/snokam/voice-vibecoder
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.12.3"
7
+ version = "2.14.0"
8
8
  description = "Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.12.3"
6
+ __version__ = "2.14.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -35,22 +35,34 @@ def _detect_git_root() -> Path:
35
35
 
36
36
  def _apply_env_overrides(settings: RealtimeSettings) -> RealtimeSettings:
37
37
  """Override settings with VOICE_* environment variables when present."""
38
- env_map = {
38
+ applied = []
39
+
40
+ # Top-level overrides
41
+ simple_map = {
39
42
  "VOICE_PROVIDER": "provider",
40
43
  "VOICE_API_KEY": "api_key",
41
- "VOICE_AZURE_ENDPOINT": "azure_endpoint",
42
- "VOICE_AZURE_DEPLOYMENT": "azure_deployment",
43
44
  "VOICE_LANGUAGE": "language",
44
45
  "VOICE_PERMISSION_MODE": "permission_mode",
45
46
  }
46
- applied = []
47
- for env_var, attr in env_map.items():
47
+ for env_var, attr in simple_map.items():
48
48
  value = os.environ.get(env_var)
49
49
  if value:
50
50
  setattr(settings, attr, value)
51
51
  safe = env_var if "KEY" not in env_var else f"{env_var}=***"
52
52
  applied.append(safe)
53
53
 
54
+ # Nested provider overrides
55
+ nested_map = {
56
+ "VOICE_AZURE_ENDPOINT": (settings.azure, "endpoint"),
57
+ "VOICE_AZURE_DEPLOYMENT": (settings.azure, "deployment"),
58
+ "VOICE_GEMINI_MODEL": (settings.gemini, "model"),
59
+ }
60
+ for env_var, (obj, attr) in nested_map.items():
61
+ value = os.environ.get(env_var)
62
+ if value:
63
+ setattr(obj, attr, value)
64
+ applied.append(env_var)
65
+
54
66
  if applied:
55
67
  logger.info("Env overrides applied: %s", ", ".join(applied))
56
68
  return settings
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from dataclasses import dataclass, asdict, field
6
7
  from pathlib import Path
7
8
  from typing import Literal
8
9
 
@@ -228,7 +229,7 @@ def get_system_instructions(
228
229
  return base
229
230
 
230
231
 
231
- Provider = Literal["openai", "azure"]
232
+ Provider = Literal["openai", "azure", "gemini"]
232
233
 
233
234
  VOICES = ["ash", "ballad", "coral", "sage", "shimmer", "alloy", "echo", "verse"]
234
235
 
@@ -239,6 +240,10 @@ AZURE_DEFAULT_ENDPOINT = ""
239
240
  AZURE_DEFAULT_DEPLOYMENT = ""
240
241
  AZURE_API_VERSION = "2024-10-01-preview"
241
242
 
243
+ GEMINI_WS_URL = "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"
244
+ GEMINI_DEFAULT_MODEL = "models/gemini-2.5-flash-native-audio-latest"
245
+ GEMINI_VOICES = ["Kore", "Aoede", "Charon", "Fenrir", "Puck", "Leda", "Orus", "Zephyr"]
246
+
242
247
  # Module-level configurable path — set via configure_paths()
243
248
  from voice_vibecoder.app_config import _default_config_dir
244
249
 
@@ -259,6 +264,38 @@ def configure_paths(config_dir: Path) -> None:
259
264
  SETTINGS_PATH = config_dir / "realtime.json"
260
265
 
261
266
 
267
+ @dataclass
268
+ class OpenAIConfig:
269
+ """Per-provider settings for OpenAI."""
270
+ model: str = OPENAI_DEFAULT_MODEL
271
+ voice: str = "ash"
272
+ vad_threshold: float = 0.8
273
+ silence_duration_ms: int = 700
274
+ prefix_padding_ms: int = 300
275
+
276
+
277
+ @dataclass
278
+ class AzureConfig:
279
+ """Per-provider settings for Azure OpenAI."""
280
+ endpoint: str = AZURE_DEFAULT_ENDPOINT
281
+ deployment: str = AZURE_DEFAULT_DEPLOYMENT
282
+ voice: str = "ash"
283
+ vad_threshold: float = 0.8
284
+ silence_duration_ms: int = 700
285
+ prefix_padding_ms: int = 300
286
+
287
+
288
+ @dataclass
289
+ class GeminiConfig:
290
+ """Per-provider settings for Gemini."""
291
+ model: str = GEMINI_DEFAULT_MODEL
292
+ voice: str = "Puck"
293
+ start_sensitivity: str = "HIGH" # "HIGH" or "LOW"
294
+ end_sensitivity: str = "LOW" # "HIGH" or "LOW"
295
+ silence_duration_ms: int = 700
296
+ prefix_padding_ms: int = 100
297
+
298
+
262
299
  class RealtimeSettings:
263
300
  """Persisted settings for the Realtime voice coding feature."""
264
301
 
@@ -266,17 +303,10 @@ class RealtimeSettings:
266
303
  self,
267
304
  provider: Provider = "openai",
268
305
  api_key: str = "",
269
- # OpenAI
270
- openai_model: str = OPENAI_DEFAULT_MODEL,
271
- # Azure
272
- azure_endpoint: str = AZURE_DEFAULT_ENDPOINT,
273
- azure_deployment: str = AZURE_DEFAULT_DEPLOYMENT,
274
- # Voice & session
275
- voice: str = "ash",
276
- # VAD — higher threshold = less sensitive to background noise
277
- vad_threshold: float = 0.8,
278
- silence_duration_ms: int = 700,
279
- prefix_padding_ms: int = 300,
306
+ # Per-provider configs
307
+ openai: OpenAIConfig | None = None,
308
+ azure: AzureConfig | None = None,
309
+ gemini: GeminiConfig | None = None,
280
310
  # Agent
281
311
  agent_timeout: int = 300,
282
312
  # Language
@@ -290,33 +320,51 @@ class RealtimeSettings:
290
320
  ) -> None:
291
321
  self.provider = provider
292
322
  self.api_key = api_key
293
- self.openai_model = openai_model
294
- self.azure_endpoint = azure_endpoint
295
- self.azure_deployment = azure_deployment
296
- self.voice = voice
297
- self.vad_threshold = vad_threshold
298
- self.silence_duration_ms = silence_duration_ms
299
- self.prefix_padding_ms = prefix_padding_ms
323
+ self.openai = openai or OpenAIConfig()
324
+ self.azure = azure or AzureConfig()
325
+ self.gemini = gemini or GeminiConfig()
300
326
  self.agent_timeout = agent_timeout
301
327
  self.language = language
302
328
  self.permission_mode = permission_mode
303
329
  self.input_mode = input_mode
304
330
  self.enabled_agents: list[str] = enabled_agents or ["claude"]
305
331
 
332
+ @property
333
+ def active_voice(self) -> str:
334
+ """Return the voice setting for the active provider."""
335
+ if self.provider == "azure":
336
+ return self.azure.voice
337
+ if self.provider == "gemini":
338
+ return self.gemini.voice
339
+ return self.openai.voice
340
+
341
+ @property
342
+ def active_provider_config(self) -> OpenAIConfig | AzureConfig | GeminiConfig:
343
+ """Return the config object for the active provider."""
344
+ if self.provider == "azure":
345
+ return self.azure
346
+ if self.provider == "gemini":
347
+ return self.gemini
348
+ return self.openai
349
+
306
350
  @property
307
351
  def ws_url(self) -> str:
308
352
  if self.provider == "azure":
309
353
  return (
310
- f"wss://{self.azure_endpoint}/openai/realtime"
354
+ f"wss://{self.azure.endpoint}/openai/realtime"
311
355
  f"?api-version={AZURE_API_VERSION}"
312
- f"&deployment={self.azure_deployment}"
356
+ f"&deployment={self.azure.deployment}"
313
357
  )
314
- return f"{OPENAI_WS_URL}?model={self.openai_model}"
358
+ if self.provider == "gemini":
359
+ return f"{GEMINI_WS_URL}?key={self.api_key}"
360
+ return f"{OPENAI_WS_URL}?model={self.openai.model}"
315
361
 
316
362
  @property
317
363
  def ws_headers(self) -> dict[str, str]:
318
364
  if self.provider == "azure":
319
365
  return {"api-key": self.api_key}
366
+ if self.provider == "gemini":
367
+ return {}
320
368
  return {
321
369
  "Authorization": f"Bearer {self.api_key}",
322
370
  "OpenAI-Beta": "realtime=v1",
@@ -329,8 +377,10 @@ class RealtimeSettings:
329
377
  @property
330
378
  def provider_label(self) -> str:
331
379
  if self.provider == "azure":
332
- return f"Azure ({self.azure_deployment})"
333
- return f"OpenAI ({self.openai_model})"
380
+ return f"Azure ({self.azure.deployment})"
381
+ if self.provider == "gemini":
382
+ return f"Gemini ({self.gemini.model})"
383
+ return f"OpenAI ({self.openai.model})"
334
384
 
335
385
  def save(self) -> None:
336
386
  SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -340,13 +390,9 @@ class RealtimeSettings:
340
390
  return {
341
391
  "provider": self.provider,
342
392
  "api_key": self.api_key,
343
- "openai_model": self.openai_model,
344
- "azure_endpoint": self.azure_endpoint,
345
- "azure_deployment": self.azure_deployment,
346
- "voice": self.voice,
347
- "vad_threshold": self.vad_threshold,
348
- "silence_duration_ms": self.silence_duration_ms,
349
- "prefix_padding_ms": self.prefix_padding_ms,
393
+ "openai": asdict(self.openai),
394
+ "azure": asdict(self.azure),
395
+ "gemini": asdict(self.gemini),
350
396
  "agent_timeout": self.agent_timeout,
351
397
  "language": self.language,
352
398
  "permission_mode": self.permission_mode,
@@ -360,16 +406,15 @@ class RealtimeSettings:
360
406
  return cls()
361
407
  try:
362
408
  data = json.loads(SETTINGS_PATH.read_text())
409
+ # Migrate flat format → nested format
410
+ if "openai_model" in data or "vad_threshold" in data:
411
+ data = cls._migrate_flat(data)
363
412
  return cls(
364
413
  provider=data.get("provider", "openai"),
365
414
  api_key=data.get("api_key", ""),
366
- openai_model=data.get("openai_model", OPENAI_DEFAULT_MODEL),
367
- azure_endpoint=data.get("azure_endpoint", AZURE_DEFAULT_ENDPOINT),
368
- azure_deployment=data.get("azure_deployment", AZURE_DEFAULT_DEPLOYMENT),
369
- voice=data.get("voice", "ash"),
370
- vad_threshold=data.get("vad_threshold", 0.8),
371
- silence_duration_ms=data.get("silence_duration_ms", 700),
372
- prefix_padding_ms=data.get("prefix_padding_ms", 300),
415
+ openai=cls._load_openai(data.get("openai", {})),
416
+ azure=cls._load_azure(data.get("azure", {})),
417
+ gemini=cls._load_gemini(data.get("gemini", {})),
373
418
  agent_timeout=data.get("agent_timeout", 300),
374
419
  language=data.get("language", "en"),
375
420
  permission_mode=data.get("permission_mode", "bypass"),
@@ -379,6 +424,72 @@ class RealtimeSettings:
379
424
  except (json.JSONDecodeError, OSError):
380
425
  return cls()
381
426
 
427
+ @staticmethod
428
+ def _load_openai(d: dict) -> OpenAIConfig:
429
+ return OpenAIConfig(
430
+ model=d.get("model", OPENAI_DEFAULT_MODEL),
431
+ voice=d.get("voice", "ash"),
432
+ vad_threshold=d.get("vad_threshold", 0.8),
433
+ silence_duration_ms=d.get("silence_duration_ms", 700),
434
+ prefix_padding_ms=d.get("prefix_padding_ms", 300),
435
+ )
436
+
437
+ @staticmethod
438
+ def _load_azure(d: dict) -> AzureConfig:
439
+ return AzureConfig(
440
+ endpoint=d.get("endpoint", AZURE_DEFAULT_ENDPOINT),
441
+ deployment=d.get("deployment", AZURE_DEFAULT_DEPLOYMENT),
442
+ voice=d.get("voice", "ash"),
443
+ vad_threshold=d.get("vad_threshold", 0.8),
444
+ silence_duration_ms=d.get("silence_duration_ms", 700),
445
+ prefix_padding_ms=d.get("prefix_padding_ms", 300),
446
+ )
447
+
448
+ @staticmethod
449
+ def _load_gemini(d: dict) -> GeminiConfig:
450
+ return GeminiConfig(
451
+ model=d.get("model", GEMINI_DEFAULT_MODEL),
452
+ voice=d.get("voice", "Puck"),
453
+ start_sensitivity=d.get("start_sensitivity", "HIGH"),
454
+ end_sensitivity=d.get("end_sensitivity", "LOW"),
455
+ silence_duration_ms=d.get("silence_duration_ms", 700),
456
+ prefix_padding_ms=d.get("prefix_padding_ms", 100),
457
+ )
458
+
459
+ @staticmethod
460
+ def _migrate_flat(data: dict) -> dict:
461
+ """Migrate old flat-key format into nested provider dicts."""
462
+ voice = data.get("voice", "ash")
463
+ vad_t = data.get("vad_threshold", 0.8)
464
+ silence = data.get("silence_duration_ms", 700)
465
+ prefix = data.get("prefix_padding_ms", 300)
466
+
467
+ data["openai"] = {
468
+ "model": data.pop("openai_model", OPENAI_DEFAULT_MODEL),
469
+ "voice": voice,
470
+ "vad_threshold": vad_t,
471
+ "silence_duration_ms": silence,
472
+ "prefix_padding_ms": prefix,
473
+ }
474
+ data["azure"] = {
475
+ "endpoint": data.pop("azure_endpoint", AZURE_DEFAULT_ENDPOINT),
476
+ "deployment": data.pop("azure_deployment", AZURE_DEFAULT_DEPLOYMENT),
477
+ "voice": voice,
478
+ "vad_threshold": vad_t,
479
+ "silence_duration_ms": silence,
480
+ "prefix_padding_ms": prefix,
481
+ }
482
+ data["gemini"] = {
483
+ "model": data.pop("gemini_model", GEMINI_DEFAULT_MODEL),
484
+ "voice": voice if voice in GEMINI_VOICES else "Puck",
485
+ "start_sensitivity": "HIGH",
486
+ "end_sensitivity": "HIGH",
487
+ }
488
+ # Remove migrated flat keys
489
+ for k in ("voice", "vad_threshold", "silence_duration_ms", "prefix_padding_ms"):
490
+ data.pop(k, None)
491
+ return data
492
+
382
493
  @staticmethod
383
494
  def clear() -> None:
384
495
  try:
@@ -9,12 +9,14 @@ Protocol (client -> server):
9
9
  {"type": "sync_state"} — request full state sync from backend
10
10
  {"type": "audio", "data": "<base64 PCM16 24kHz>"}
11
11
  {"type": "audio.commit"}
12
+ {"type": "update_settings", "settings": {...}} — partial settings update, reconnects provider
12
13
 
13
14
  Protocol (server -> client):
14
15
  {"type": "auth_ok"}
15
16
  {"type": "audio", "data": "<base64>"}
16
17
  {"type": "transcript", "role": "user"|"assistant", "text": "..."}
17
- {"type": "status", "status": "ready"|"listening"|"processing"|"responding"}
18
+ {"type": "status", "status": "ready"|"listening"|"processing"|"responding"|"reconnecting"}
19
+ {"type": "settings_updated", "settings": {...}}
18
20
  {"type": "agent_output", "instanceId": "...", "line": "..."}
19
21
  {"type": "instance_status", "instanceId": "...", "status": "...", "branch": "..."}
20
22
  {"type": "tool_call", "name": "...", "arguments": {...}}
@@ -29,6 +31,7 @@ import asyncio
29
31
  import json
30
32
  import logging
31
33
  import os
34
+ from dataclasses import asdict, fields
32
35
  from pathlib import Path
33
36
  from typing import Any
34
37
 
@@ -40,7 +43,7 @@ from voice_vibecoder.app_config import VoiceConfig
40
43
  from voice_vibecoder.config import RealtimeSettings
41
44
  from voice_vibecoder.instances import InstanceStatus
42
45
  from voice_vibecoder.session import RealtimeSession
43
- from voice_vibecoder.voice_providers.openai import OpenAIVoiceProvider
46
+ from voice_vibecoder.voice_providers import create_provider
44
47
 
45
48
  logger = logging.getLogger(__name__)
46
49
 
@@ -49,6 +52,34 @@ logger = logging.getLogger(__name__)
49
52
  AUTH_TIMEOUT_SECONDS = 10
50
53
 
51
54
 
55
+ def _merge_settings(settings: RealtimeSettings, incoming: dict[str, Any]) -> None:
56
+ """Merge a partial settings dict into an existing RealtimeSettings.
57
+
58
+ Top-level scalar keys (provider, language, etc.) are replaced directly.
59
+ Nested provider dicts (openai, azure, gemini) are merged at the field level
60
+ so the frontend can send e.g. ``{"gemini": {"model": "..."}}`` without
61
+ needing to include every field.
62
+ """
63
+ from voice_vibecoder.config import OpenAIConfig, AzureConfig, GeminiConfig
64
+
65
+ _PROVIDER_ATTRS = {
66
+ "openai": OpenAIConfig,
67
+ "azure": AzureConfig,
68
+ "gemini": GeminiConfig,
69
+ }
70
+
71
+ for key, value in incoming.items():
72
+ if key in _PROVIDER_ATTRS and isinstance(value, dict):
73
+ # Merge sub-keys into the existing dataclass
74
+ cfg = getattr(settings, key)
75
+ valid_fields = {f.name for f in fields(cfg)}
76
+ for sub_key, sub_value in value.items():
77
+ if sub_key in valid_fields:
78
+ setattr(cfg, sub_key, sub_value)
79
+ elif hasattr(settings, key):
80
+ setattr(settings, key, value)
81
+
82
+
52
83
  class WebSocketBridge:
53
84
  """One bridge per browser connection. Thin adapter between WS frames and RealtimeSession."""
54
85
 
@@ -68,12 +99,10 @@ class WebSocketBridge:
68
99
  self._voice_config = voice_config
69
100
  self._user_email = user_email
70
101
  self._user_jwt = user_jwt
102
+ self._messages_task: asyncio.Task | None = None
71
103
 
72
104
  async def run(self) -> None:
73
- provider = OpenAIVoiceProvider(
74
- ws_url=self._settings.ws_url,
75
- ws_headers=self._settings.ws_headers,
76
- )
105
+ provider = create_provider(self._settings)
77
106
 
78
107
  self._session = RealtimeSession(
79
108
  provider=provider,
@@ -102,21 +131,17 @@ class WebSocketBridge:
102
131
  # Sending it here too would cause duplicate output lines.
103
132
  self._send({"type": "status", "status": "ready"})
104
133
 
105
- receive_task = asyncio.create_task(self._receive_loop())
106
- messages_task = asyncio.create_task(self._session.handle_messages())
134
+ self._messages_task = asyncio.create_task(self._session.handle_messages())
107
135
 
108
136
  try:
109
- done, pending = await asyncio.wait(
110
- [receive_task, messages_task],
111
- return_when=asyncio.FIRST_COMPLETED,
112
- )
113
- for task in pending:
114
- task.cancel()
137
+ await self._receive_loop()
138
+ finally:
139
+ if self._messages_task:
140
+ self._messages_task.cancel()
115
141
  try:
116
- await task
142
+ await self._messages_task
117
143
  except (asyncio.CancelledError, Exception):
118
144
  pass
119
- finally:
120
145
  if self._session.connected:
121
146
  await self._session.disconnect()
122
147
 
@@ -142,6 +167,8 @@ class WebSocketBridge:
142
167
  if text and self._session:
143
168
  # Transcript sent by session.inject_user_message
144
169
  await self._session.inject_user_message(text)
170
+ elif msg_type == "update_settings":
171
+ await self._handle_settings_update(msg.get("settings", {}))
145
172
  elif msg_type == "sync_state":
146
173
  # Frontend requests full state sync from backend
147
174
  self._send({"type": "status", "status": "syncing"})
@@ -183,6 +210,42 @@ class WebSocketBridge:
183
210
  "line": f"text:{inst.output_buffer}",
184
211
  })
185
212
 
213
+ async def _handle_settings_update(self, incoming: dict[str, Any]) -> None:
214
+ """Apply a partial settings update, save to disk, and reconnect the provider."""
215
+ if not incoming:
216
+ self._send({"type": "error", "message": "Empty settings update"})
217
+ return
218
+
219
+ _merge_settings(self._settings, incoming)
220
+ self._settings.save()
221
+ logger.info("Settings updated: %s", list(incoming.keys()))
222
+
223
+ if self._session:
224
+ self._send({"type": "status", "status": "reconnecting"})
225
+ try:
226
+ # Stop the current message listener before swapping providers
227
+ if self._messages_task:
228
+ self._messages_task.cancel()
229
+ try:
230
+ await self._messages_task
231
+ except (asyncio.CancelledError, Exception):
232
+ pass
233
+
234
+ await self._session.update_provider(self._settings)
235
+
236
+ # Restart the message listener with the new provider
237
+ self._messages_task = asyncio.create_task(
238
+ self._session.handle_messages()
239
+ )
240
+ self._send({"type": "status", "status": "ready"})
241
+ self._send({
242
+ "type": "settings_updated",
243
+ "settings": self._settings._to_dict(),
244
+ })
245
+ except Exception as e:
246
+ logger.error("Failed to reconnect after settings update: %s", e, exc_info=True)
247
+ self._send({"type": "error", "message": f"Reconnect failed: {e}"})
248
+
186
249
  # -- Callbacks (called from RealtimeSession, possibly from background threads) --
187
250
 
188
251
  def _send(self, msg: dict[str, Any]) -> None:
@@ -30,6 +30,7 @@ from voice_vibecoder.voice_providers import (
30
30
  AudioCommitted,
31
31
  AudioDelta,
32
32
  FunctionCall,
33
+ GeminiVADSettings,
33
34
  ResponseCreated,
34
35
  ResponseDone,
35
36
  SpeechStarted,
@@ -127,6 +128,22 @@ class RealtimeSession:
127
128
  if self._on_status:
128
129
  self._on_status("connected")
129
130
 
131
+ async def update_provider(self, new_settings: RealtimeSettings) -> None:
132
+ """Swap the voice provider and reconnect with new settings.
133
+
134
+ Preserves the InstanceRegistry, callbacks, and all agent instances.
135
+ Only the voice provider connection is torn down and recreated.
136
+ """
137
+ self._settings = new_settings
138
+ # Disconnect old provider
139
+ await self._provider.disconnect()
140
+ # Create new provider
141
+ from voice_vibecoder.voice_providers import create_provider
142
+ self._provider = create_provider(new_settings)
143
+ # Connect and reconfigure
144
+ await self._provider.connect()
145
+ await self.configure_session()
146
+
130
147
  async def configure_session(self) -> None:
131
148
  from voice_vibecoder.tools.agent_task import warm_mcp_cache
132
149
  from voice_vibecoder.tools.handlers import get_active_instances_text, get_startup_context
@@ -142,17 +159,26 @@ class RealtimeSession:
142
159
  active_instances=get_active_instances_text(),
143
160
  )
144
161
 
145
- vad: VADSettings | None = None
162
+ vad: VADSettings | GeminiVADSettings | None = None
146
163
  if s.input_mode != "ptt":
147
- vad = VADSettings(
148
- threshold=s.vad_threshold,
149
- silence_duration_ms=s.silence_duration_ms,
150
- prefix_padding_ms=s.prefix_padding_ms,
151
- )
164
+ if s.provider == "gemini":
165
+ vad = GeminiVADSettings(
166
+ start_sensitivity=s.gemini.start_sensitivity,
167
+ end_sensitivity=s.gemini.end_sensitivity,
168
+ silence_duration_ms=s.gemini.silence_duration_ms,
169
+ prefix_padding_ms=s.gemini.prefix_padding_ms,
170
+ )
171
+ else:
172
+ cfg = s.openai if s.provider == "openai" else s.azure
173
+ vad = VADSettings(
174
+ threshold=cfg.vad_threshold,
175
+ silence_duration_ms=cfg.silence_duration_ms,
176
+ prefix_padding_ms=cfg.prefix_padding_ms,
177
+ )
152
178
 
153
179
  await self._provider.configure(
154
180
  instructions=instructions,
155
- voice=s.voice,
181
+ voice=s.active_voice,
156
182
  tools=get_tool_definitions(),
157
183
  vad=vad,
158
184
  )
@@ -174,7 +174,7 @@ class VoiceCodingScreen(PanelMixin, CallbackMixin, Screen):
174
174
  self._input_mode = settings.input_mode
175
175
 
176
176
  self.app.call_from_thread(
177
- self._log, f"[dim]{settings.provider_label} | voice: {settings.voice}[/dim]"
177
+ self._log, f"[dim]{settings.provider_label} | voice: {settings.active_voice}[/dim]"
178
178
  )
179
179
  self.app.call_from_thread(self._log, "[dim]Connecting...[/dim]")
180
180
  self.app.call_from_thread(self._start_session, settings)
@@ -315,14 +315,10 @@ class VoiceCodingScreen(PanelMixin, CallbackMixin, Screen):
315
315
  # -- Session lifecycle --
316
316
 
317
317
  def _start_session(self, settings) -> None:
318
- from voice_vibecoder.voice_providers.openai import OpenAIVoiceProvider
318
+ from voice_vibecoder.voice_providers import create_provider
319
319
  from voice_vibecoder.session import RealtimeSession
320
320
 
321
- provider = OpenAIVoiceProvider(
322
- ws_url=settings.ws_url,
323
- ws_headers=settings.ws_headers,
324
- transcription_model="gpt-4o-mini-transcribe",
325
- )
321
+ provider = create_provider(settings)
326
322
 
327
323
  self._session = RealtimeSession(
328
324
  provider=provider,