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.
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/CHANGELOG.md +16 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/PKG-INFO +1 -1
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/pyproject.toml +1 -1
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/__init__.py +1 -1
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/cli_serve.py +17 -5
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/config.py +149 -38
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/server.py +79 -16
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/session.py +33 -7
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/screen.py +3 -7
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/setup.py +195 -56
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/wizard.py +44 -14
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/voice_providers/__init__.py +26 -2
- voice_vibecoder-2.14.0/src/voice_vibecoder/voice_providers/gemini.py +395 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/.github/workflows/ci.yml +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/.github/workflows/release.yml +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/.gitignore +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/LICENSE +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/README.md +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/app.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/index.html +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/app_config.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/audio.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/openclaw.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/instances.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/__init__.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/definitions.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/tools/handlers.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/__init__.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/panels.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/state.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/styles.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
- {voice_vibecoder-2.12.3 → voice_vibecoder-2.14.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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.
|
|
294
|
-
self.
|
|
295
|
-
self.
|
|
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.
|
|
354
|
+
f"wss://{self.azure.endpoint}/openai/realtime"
|
|
311
355
|
f"?api-version={AZURE_API_VERSION}"
|
|
312
|
-
f"&deployment={self.
|
|
356
|
+
f"&deployment={self.azure.deployment}"
|
|
313
357
|
)
|
|
314
|
-
|
|
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.
|
|
333
|
-
|
|
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
|
-
"
|
|
344
|
-
"
|
|
345
|
-
"
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
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.
|
|
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
|
|
318
|
+
from voice_vibecoder.voice_providers import create_provider
|
|
319
319
|
from voice_vibecoder.session import RealtimeSession
|
|
320
320
|
|
|
321
|
-
provider =
|
|
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,
|