Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
40 changes: 34 additions & 6 deletions apps/config_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
GROUP_RUNTIME,
GROUPS,
MUTEX_RADIOS,
TOGGLE_CHECKS,
)

REPO_ROOT = Path(__file__).resolve().parents[1]
Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand Down
58 changes: 47 additions & 11 deletions apps/config_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions apps/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions src/eva/user_simulator/openai_realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/unit/user_simulator/test_openai_realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down