diff --git a/.env.example b/.env.example index aee475dc..88b1841b 100644 --- a/.env.example +++ b/.env.example @@ -330,11 +330,13 @@ EVA_EN_USER_M=your_elevenlabs_agent_id_for_default_user_m #i Ambient noise to mix into user speech. #d enum #e airport_gate,baby_crying,background_music,bad_connection_static,coffee_shop,loud_construction,nyc_street,road_noise +#x background_noise_enabled=true #v EVA_PERTURBATION__BACKGROUND_NOISE=coffee_shop #i Signal-to-noise ratio in dB. Higher = cleaner user speech. #d float #r 0,40,1 +#x background_noise_enabled=true #v EVA_PERTURBATION__SNR_DB=15 # --- Connection degradation --- diff --git a/apps/config_editor.py b/apps/config_editor.py index 1a62767a..dc512f2d 100644 --- a/apps/config_editor.py +++ b/apps/config_editor.py @@ -37,6 +37,7 @@ GROUP_RUNTIME, GROUPS, MUTEX_RADIOS, + TOGGLE_CHECKS, ) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -107,24 +108,36 @@ def _init_state() -> None: return parsed = parse_env_example(ENV_EXAMPLE_PATH) st.session_state.parsed = parsed - existing = load_env(ENV_PATH) + loaded = load_env(ENV_PATH) + all_values = loaded.all_values values: dict[str, Any] = {} for var in parsed.vars: - raw = existing.get(var.name) + raw = all_values.get(var.name) if raw is None and var.is_active: raw = var.example_value.strip().strip("'\"") values[var.name] = _coerce(var.widget, raw or "") - for name, raw in existing.items(): + for name, raw in all_values.items(): if name not in {v.name for v in parsed.vars}: values[name] = raw st.session_state.field_values = values - st.session_state.loaded_keys = set(existing.keys()) - st.session_state.pipeline_mode = _detect_pipeline_mode(existing) - st.session_state.perturbation_mode = _detect_perturbation_mode(existing) + st.session_state.loaded_keys = loaded.active_names + st.session_state.pipeline_mode = _detect_pipeline_mode(loaded.active) + st.session_state.perturbation_mode = _detect_perturbation_mode(loaded.active) # Initialise all mutex radio states for mx in MUTEX_RADIOS: if mx.state_key not in st.session_state: st.session_state[mx.state_key] = st.session_state.get(mx.state_key, mx.default) + # Initialise toggle checks — derive initial state from whether any gated var has a value + _gated_vars: dict[str, list[str]] = {} + for var in parsed.vars: + for cond_key, cond_val in var.conditions: + if cond_val.strip().lower() == "true": + _gated_vars.setdefault(cond_key, []).append(var.name) + for tc in TOGGLE_CHECKS: + if tc.state_key not in st.session_state: + gated = _gated_vars.get(tc.state_key, []) + has_value = any(loaded.active.get(n) or loaded.inactive.get(n) for n in gated) + st.session_state[tc.state_key] = has_value or tc.default st.session_state.initialized = True @@ -143,6 +156,8 @@ def _is_visible_av(var: AnnotatedVar) -> bool: actual = st.session_state.get(cond_key) if actual is None: actual = st.session_state.get("field_values", {}).get(cond_key) + if isinstance(actual, bool): + actual = "true" if actual else "false" allowed = {v.strip() for v in cond_val.split(",") if v.strip()} if actual not in allowed: return False @@ -478,6 +493,17 @@ def _render_group(group: str) -> None: ) st.divider() + # Render standalone toggle checkboxes for this group + for tc in TOGGLE_CHECKS: + if tc.group == group: + current_tc = st.session_state.get(tc.state_key, tc.default) + st.session_state[tc.state_key] = st.checkbox( + tc.label, + value=bool(current_tc), + help=tc.help or None, + key=f"toggle_{tc.state_key}", + ) + # Template vars for this group group_vars = [v for v in parsed.vars if v.group == group] @@ -565,6 +591,8 @@ def _build_serialized() -> str: mode_state: dict[str, str] = {} for mx in MUTEX_RADIOS: mode_state[mx.state_key] = st.session_state.get(mx.state_key, mx.default) + for tc in TOGGLE_CHECKS: + mode_state[tc.state_key] = "true" if st.session_state.get(tc.state_key, tc.default) else "false" mode_state.update({k: str(v) for k, v in values.items() if isinstance(v, str)}) disabled = compute_disabled(parsed, **mode_state) # Split extras by auto-routing: inline into their parent section or fall through to Misc diff --git a/apps/config_io.py b/apps/config_io.py index a22ca740..cd38ca86 100644 --- a/apps/config_io.py +++ b/apps/config_io.py @@ -236,35 +236,71 @@ def emit_var(name: str, is_active: bool, value_head: str, line_start: int) -> in return ParsedEnvExample(lines=raw_lines, vars=vars_list) -def load_env(path: str | Path) -> dict[str, str]: - """Read an existing .env into a flat {NAME: value} dict. +@dataclass +class LoadedEnv: + """Result of parsing a .env file, split by active/inactive status.""" + + active: dict[str, str] # NAME=value lines + inactive: dict[str, str] # #v NAME=value lines (saved-but-disabled) + + @property + def all_values(self) -> dict[str, str]: + """Merged dict; active values take priority over inactive for the same name.""" + return {**self.inactive, **self.active} + + @property + def active_names(self) -> set[str]: + return set(self.active) - Commented-out lines (including #v lines) are skipped. + @property + def inactive_names(self) -> set[str]: + return set(self.inactive) - set(self.active) + + +def load_env(path: str | Path) -> LoadedEnv: + """Parse a .env file into active and inactive value dicts. + + Active lines (NAME=value) → LoadedEnv.active + Inactive lines (#v NAME=value) → LoadedEnv.inactive Values have surrounding quotes stripped. """ p = Path(path) if not p.exists(): - return {} - out: dict[str, str] = {} + return LoadedEnv(active={}, inactive={}) + active: dict[str, str] = {} + inactive: dict[str, str] = {} i = 0 lines = p.read_text().splitlines(keepends=False) while i < len(lines): line = lines[i] stripped = line.strip() - if stripped.startswith("#") or not stripped: - i += 1 - continue - if "=" in stripped: + + # Active variable + if not stripped.startswith("#") and "=" in stripped: name, _, value_head = stripped.partition("=") name = name.strip() if _NAME_RE.match(name): end_idx = _consume_quoted_continuation(lines, i, value_head) raw = "\n".join([value_head, *lines[i + 1 : end_idx + 1]]) if end_idx > i else value_head - out[name] = _unquote(raw.strip()) + active[name] = _unquote(raw.strip()) i = end_idx + 1 continue + + # Inactive variable (#v NAME=value) — only load if it carries a real value + if stripped.startswith("#v ") and "=" in stripped: + rest = stripped[3:].strip() + name, _, value_head = rest.partition("=") + name = name.strip() + if _NAME_RE.match(name) and value_head.strip(): + end_idx = _consume_quoted_continuation(lines, i, value_head) + raw = "\n".join([value_head, *lines[i + 1 : end_idx + 1]]) if end_idx > i else value_head + inactive.setdefault(name, _unquote(raw.strip())) + i = end_idx + 1 + continue + i += 1 - return out + + return LoadedEnv(active=active, inactive=inactive) def _unquote(value: str) -> str: diff --git a/apps/config_schema.py b/apps/config_schema.py index fa1e04ed..d8c7b96c 100644 --- a/apps/config_schema.py +++ b/apps/config_schema.py @@ -33,6 +33,28 @@ ] +@dataclass +class ToggleCheck: + """A standalone checkbox that gates visibility of related vars via #x conditions.""" + + state_key: str # st.session_state key; #x conditions reference this key with value "true" + group: str + label: str + help: str = "" + default: bool = False + + +TOGGLE_CHECKS: list[ToggleCheck] = [ + ToggleCheck( + state_key="background_noise_enabled", + group=GROUP_PERTURBATIONS, + label="Enable background noise", + help="Mix ambient audio into user speech. Requires assets — see scripts/download_noise_assets.py.", + default=False, + ), +] + + @dataclass class MutexRadio: """A UI radio button that enforces mutual exclusion among a set of vars.""" diff --git a/src/eva/user_simulator/openai_realtime.py b/src/eva/user_simulator/openai_realtime.py index e056fd89..e8ac1c68 100644 --- a/src/eva/user_simulator/openai_realtime.py +++ b/src/eva/user_simulator/openai_realtime.py @@ -391,6 +391,8 @@ async def _handle_caller_event(self, event: Any) -> None: BRIDGE_SAMPLE_RATE, self._resampler_state, ) + # Realtime is kind of quiet for no reason, much better to listen to with this + pcm16_16k = audioop.mul(pcm16_16k, 2, 2.75) self._audio_interface.output(pcm16_16k) self._caller_audio_seen = True self._caller_playback_pending = True diff --git a/tests/unit/user_simulator/test_openai_realtime.py b/tests/unit/user_simulator/test_openai_realtime.py index d4af67c8..5f8405d1 100644 --- a/tests/unit/user_simulator/test_openai_realtime.py +++ b/tests/unit/user_simulator/test_openai_realtime.py @@ -162,6 +162,7 @@ def ratecv(data, width, channels, input_rate, output_rate, state): return b"converted", f"state-{len(states)}" monkeypatch.setattr("eva.user_simulator.openai_realtime.audioop.ratecv", ratecv) + monkeypatch.setattr("eva.user_simulator.openai_realtime.audioop.mul", lambda data, width, factor: data) delta = "AAAAAA==" await simulator._handle_caller_event(SimpleNamespace(type="response.output_audio.delta", delta=delta))