HearLife_Lab / app.py
geogem245's picture
Upload app.py
3d8166d verified
Raw History Blame Contribute Delete
87.6 kB
import random
from dataclasses import dataclass
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import gradio as gr
import spaces
# ============================================================
# APPLICATION CONFIGURATION
# ============================================================
APP_TITLE = "Hearing Technology Behaviour Lab"
APP_VERSION = "0.5.1"
DISCLAIMER = (
"Synthetic behavioural simulation for research and exploration. "
"It is not a clinical prediction model and does not represent "
"individual real-world patients."
)
# ============================================================
# EVIDENCE / RAG CONTEXT
# ============================================================
EVIDENCE = {
"directionality": {
"label": "Directional microphone benefit",
"description": (
"Laboratory studies commonly report roughly 3–4 dB directional "
"benefit in difficult near-field conditions; individual studies "
"also report values around 2–5 dB depending on configuration."
),
"source": "Ricketts review; Kim & Kim study; hearing-aid directionality literature",
"tier": "A β€” empirical anchor",
"default": "Fixed directional prior = 3.2 dB",
},
"bilateral_directionality": {
"label": "Bilateral spatial processing",
"description": (
"Bilateral directional processing can provide larger SNR advantages "
"than a single first-order directional microphone under favourable conditions."
),
"source": "Ricketts et al. / hearing-aid prescription literature",
"tier": "A/B β€” empirical anchor with configuration dependence",
"default": "Binaural beamformer prior = 5.5 dB",
},
"adaptive_directionality": {
"label": "Adaptive directionality",
"description": (
"Adaptive directional processing improves speech recognition over "
"omnidirectional processing in several simulated real-world scenes, "
"while the realised advantage depends on noise movement and geometry."
),
"source": "Evaluation of adaptive directional-microphone hearing aids",
"tier": "A/B β€” empirical anchor with scene dependence",
"default": "Adaptive directional prior = 3.8 dB",
},
"dnn": {
"label": "DNN / learned noise reduction",
"description": (
"Recent controlled work reports benefits from DNN processing, particularly "
"when combined with beamforming, but outcomes vary with noise type and "
"spatial configuration. The model therefore uses a broad prior rather than "
"a single claimed DNN gain."
),
"source": "Recent DNN hearing-aid speech-recognition/listening-effort literature",
"tier": "B β€” literature-informed",
"default": "DNN beamformer prior = 4.8 dB",
},
"listening_effort": {
"label": "Listening effort",
"description": (
"Directional microphone processing has been shown to reduce listening effort "
"in controlled speech-in-noise tasks."
),
"source": "Picou & Ricketts; Desjardins",
"tier": "A β€” empirical direction of effect",
"default": "Effort responds to effective SNR and task difficulty",
},
"sound_quality": {
"label": "Signal fidelity / sound quality",
"description": (
"Noise reduction can improve effective SNR while also introducing signal "
"distortion; stronger processing can therefore create a performance–fidelity trade-off."
),
"source": "Hearing-aid noise-reduction / HASQI literature",
"tier": "A/B β€” empirical direction of effect",
"default": "Processing artefact penalty rises with processing intensity",
},
"satisfaction": {
"label": "Satisfaction",
"description": (
"MarkeTrak 2022 reported 83% overall hearing-aid satisfaction, broadly "
"consistent with 81% in 2015 and 83% in 2019."
),
"source": "MarkeTrak 2022",
"tier": "A β€” population anchor",
"default": "Population-level satisfaction anchor β‰ˆ 0.83",
},
"daily_use": {
"label": "Daily use",
"description": (
"MarkeTrak provides a useful real-world anchor for daily use and hours worn, "
"while individual use remains heterogeneous."
),
"source": "MarkeTrak survey literature",
"tier": "A β€” population anchor",
"default": "Daily-use prior β‰ˆ 0.67–0.70; wear time β‰ˆ 8–10 h/day",
},
"wearability": {
"label": "Wearability / practical burden",
"description": (
"Comfort, handling, visibility, charging and technology complexity are modelled "
"as behavioural modifiers rather than acoustic effects."
),
"source": "Behavioural modelling informed by hearing-aid user research",
"tier": "C β€” modelling construct",
"default": "Explicitly synthetic; not a prevalence estimate",
},
"spatial_performance": {
"label": "Spatial geometry",
"description": (
"Directional benefit depends on target/noise geometry, source distance, "
"reverberation and movement; frontal target access is generally more favourable "
"for conventional directional processing."
),
"source": "Directional microphone and spatial-processing literature",
"tier": "A/B β€” evidence-informed mechanism",
"default": "Scene-specific geometry modifier",
},
"intelligibility": {
"label": "Estimated intelligibility",
"description": (
"The simulation uses a logistic psychometric proxy so that an identical SNR "
"change can produce different percentage-point gains depending on starting difficulty."
),
"source": "Modelled functional construct",
"tier": "C β€” modelling construct",
"default": "Not a clinical speech-recognition score",
},
"glasses": {
"label": "Augmented listening glasses",
"description": (
"Hypothetical technology. There is no single empirical parameter set in the "
"literature that justifies treating this as an established device class."
),
"source": "Synthetic scenario / hypothesis",
"tier": "C β€” hypothetical",
"default": "Conservative 4.0 dB potential prior; wide uncertainty",
},
}
# ============================================================
# TECHNOLOGIES
# ============================================================
REFERENCE_CONDITIONS = [
"Unaided listening",
"Omnidirectional",
"Fixed directional",
"Adaptive directional",
"Beamformer",
"Binaural beamformer",
"DNN beamformer",
"Augmented listening glasses",
]
TECHNOLOGIES = {
"Omnidirectional": {
"potential_snr_db": 0.0,
"off_axis_robustness": 1.00,
"spatial_selectivity": 0.05,
"target_preservation": 0.98,
"mobility_robustness": 1.00,
"reverb_robustness": 1.00,
"competing_speech_robustness": 0.55,
"processing_intensity": 0.02,
"naturalness": 0.98,
"battery": 0.96,
"wearability": 0.92,
"visibility": 0.05,
"complexity": 0.02,
"charging_hours": 14.0,
"description": "Broad acoustic access with minimal spatial filtering.",
"evidence_tier": "A/B baseline condition",
},
"Fixed directional": {
"potential_snr_db": 3.2,
"off_axis_robustness": 0.38,
"spatial_selectivity": 0.68,
"target_preservation": 0.92,
"mobility_robustness": 0.50,
"reverb_robustness": 0.68,
"competing_speech_robustness": 0.60,
"processing_intensity": 0.24,
"naturalness": 0.88,
"battery": 0.88,
"wearability": 0.90,
"visibility": 0.05,
"complexity": 0.18,
"charging_hours": 13.0,
"description": "Stable forward-oriented directionality; strongest when geometry is favourable.",
"evidence_tier": "A β€” literature-anchored prior",
},
"Adaptive directional": {
"potential_snr_db": 3.8,
"off_axis_robustness": 0.64,
"spatial_selectivity": 0.72,
"target_preservation": 0.92,
"mobility_robustness": 0.72,
"reverb_robustness": 0.66,
"competing_speech_robustness": 0.66,
"processing_intensity": 0.34,
"naturalness": 0.87,
"battery": 0.80,
"wearability": 0.88,
"visibility": 0.05,
"complexity": 0.32,
"charging_hours": 12.5,
"description": "Directionality adapts to changing acoustic scenes and moving noise.",
"evidence_tier": "A/B β€” literature-anchored prior",
},
"Beamformer": {
"potential_snr_db": 4.5,
"off_axis_robustness": 0.68,
"spatial_selectivity": 0.80,
"target_preservation": 0.90,
"mobility_robustness": 0.68,
"reverb_robustness": 0.64,
"competing_speech_robustness": 0.68,
"processing_intensity": 0.48,
"naturalness": 0.82,
"battery": 0.72,
"wearability": 0.84,
"visibility": 0.05,
"complexity": 0.50,
"charging_hours": 11.5,
"description": "Stronger spatial target extraction with greater processing burden.",
"evidence_tier": "B β€” engineering/literature-informed prior",
},
"Binaural beamformer": {
"potential_snr_db": 5.5,
"off_axis_robustness": 0.76,
"spatial_selectivity": 0.86,
"target_preservation": 0.92,
"mobility_robustness": 0.76,
"reverb_robustness": 0.67,
"competing_speech_robustness": 0.74,
"processing_intensity": 0.60,
"naturalness": 0.83,
"battery": 0.65,
"wearability": 0.82,
"visibility": 0.05,
"complexity": 0.62,
"charging_hours": 10.5,
"description": "Uses bilateral information for stronger spatial target extraction.",
"evidence_tier": "A/B β€” literature-informed prior",
},
"DNN beamformer": {
"potential_snr_db": 4.8,
"off_axis_robustness": 0.70,
"spatial_selectivity": 0.88,
"target_preservation": 0.89,
"mobility_robustness": 0.66,
"reverb_robustness": 0.72,
"competing_speech_robustness": 0.78,
"processing_intensity": 0.76,
"naturalness": 0.77,
"battery": 0.55,
"wearability": 0.79,
"visibility": 0.05,
"complexity": 0.78,
"charging_hours": 9.5,
"description": "Learned spatial/noise separation; potentially strong but scene-dependent.",
"evidence_tier": "B β€” recent literature-informed prior",
},
"Augmented listening glasses": {
"potential_snr_db": 4.0,
"off_axis_robustness": 0.82,
"spatial_selectivity": 0.90,
"target_preservation": 0.90,
"mobility_robustness": 0.78,
"reverb_robustness": 0.68,
"competing_speech_robustness": 0.72,
"processing_intensity": 0.72,
"naturalness": 0.70,
"battery": 0.48,
"wearability": 0.62,
"visibility": 0.45,
"complexity": 0.78,
"charging_hours": 8.0,
"description": "Hypothetical wearable augmented-listening technology; parameters are deliberately uncertain.",
"evidence_tier": "C β€” hypothetical scenario",
},
}
# ============================================================
# LISTENING SCENES
# ============================================================
SCENES = {
"Quiet conversation": {
"noise": 0.10, "reverb": 0.10, "distance": 0.20, "spatial": 0.30,
"social": 0.75, "importance": 0.75,
"target_azimuth": 10, "target_mobility": 0.10, "competing_speech": 0.05,
},
"Family dinner": {
"noise": 0.55, "reverb": 0.35, "distance": 0.45, "spatial": 0.65,
"social": 0.90, "importance": 0.90,
"target_azimuth": 35, "target_mobility": 0.35, "competing_speech": 0.55,
},
"Busy restaurant": {
"noise": 0.85, "reverb": 0.65, "distance": 0.65, "spatial": 0.80,
"social": 0.90, "importance": 0.90,
"target_azimuth": 70, "target_mobility": 0.55, "competing_speech": 0.80,
},
"Work meeting": {
"noise": 0.35, "reverb": 0.40, "distance": 0.55, "spatial": 0.75,
"social": 0.80, "importance": 0.85,
"target_azimuth": 20, "target_mobility": 0.25, "competing_speech": 0.45,
},
"Walking outdoors": {
"noise": 0.35, "reverb": 0.05, "distance": 0.70, "spatial": 0.95,
"social": 0.35, "importance": 0.60,
"target_azimuth": 110, "target_mobility": 0.75, "competing_speech": 0.10,
},
"Public transport": {
"noise": 0.75, "reverb": 0.55, "distance": 0.65, "spatial": 0.75,
"social": 0.35, "importance": 0.55,
"target_azimuth": 65, "target_mobility": 0.25, "competing_speech": 0.60,
},
"Lecture / presentation": {
"noise": 0.25, "reverb": 0.55, "distance": 0.85, "spatial": 0.60,
"social": 0.65, "importance": 0.80,
"target_azimuth": 5, "target_mobility": 0.10, "competing_speech": 0.05,
},
"Social gathering": {
"noise": 0.70, "reverb": 0.45, "distance": 0.60, "spatial": 0.85,
"social": 1.00, "importance": 0.95,
"target_azimuth": 55, "target_mobility": 0.60, "competing_speech": 0.75,
},
}
# ============================================================
# SYNTHETIC USER
# ============================================================
@dataclass
class SyntheticUser:
user_id: int
hearing_loss: float
noise_difficulty: float
spatial_difficulty: float
technology_tolerance: float
comfort_sensitivity: float
social_confidence: float
engagement: float
battery_sensitivity: float
visibility_sensitivity: float
baseline_use: float
baseline_satisfaction: float
acceptance: float = 0.50
fatigue: float = 0.0
cumulative_benefit: float = 0.0
def create_user(user_id):
return SyntheticUser(
user_id=user_id,
hearing_loss=float(np.clip(np.random.normal(0.55, 0.18), 0.15, 0.95)),
noise_difficulty=float(np.clip(np.random.normal(0.55, 0.20), 0.10, 0.95)),
spatial_difficulty=float(np.clip(np.random.normal(0.45, 0.22), 0.05, 0.95)),
technology_tolerance=float(np.clip(np.random.normal(0.60, 0.20), 0.10, 0.95)),
comfort_sensitivity=float(np.clip(np.random.normal(0.55, 0.20), 0.10, 0.95)),
social_confidence=float(np.clip(np.random.normal(0.65, 0.18), 0.10, 0.95)),
engagement=float(np.clip(np.random.normal(0.65, 0.20), 0.10, 0.95)),
battery_sensitivity=float(np.clip(np.random.normal(0.50, 0.20), 0.10, 0.95)),
visibility_sensitivity=float(np.clip(np.random.normal(0.40, 0.25), 0.05, 0.95)),
# Calibrated loosely around MarkeTrak's population-level daily-use anchor,
# but individual values remain synthetic and are not prevalence estimates.
baseline_use=float(np.clip(np.random.normal(0.69, 0.14), 0.25, 0.98)),
baseline_satisfaction=float(np.clip(np.random.normal(0.83, 0.10), 0.45, 0.98)),
)
# ============================================================
# TECHNOLOGY EFFECT
# ============================================================
def _technology_profile(condition):
"""Return a technology profile. Unaided is an explicit zero-processing reference."""
if condition == "Unaided listening":
return {
"noise": 0.0, "spatial": 0.0, "target": 0.0,
"naturalness": 1.0, "battery": 1.0, "complexity": 0.0,
"snr_cap_db": 0.0, "spatial_selectivity": 0.0,
"target_preservation": 1.0, "off_axis_robustness": 1.0,
"wearability": 1.0, "visibility": 0.0,
}
return TECHNOLOGIES[condition]
def _angle_alignment(target_azimuth):
"""Conventional forward-axis alignment, 1 at 0Β° and 0 at 90Β°/180Β°."""
angle = abs(float(target_azimuth))
# Use a smooth front-to-side/back decay rather than a negative cosine.
return float(np.clip(np.cos(np.deg2rad(min(angle, 90))) ** 1.25, 0, 1))
def _scene_noise_type(scene):
if scene in {"Busy restaurant", "Social gathering", "Family dinner"}:
return "multi_talker"
if scene in {"Public transport", "Walking outdoors"}:
return "environmental"
if scene in {"Work meeting", "Lecture / presentation"}:
return "speech_dominant"
return "low_competition"
def _acoustic_scene_state(scene, user):
s = SCENES[scene]
# A transparent SNR proxy. It is intentionally not a clinical/laboratory
# measurement; it creates a common acoustic state for the technology arms.
unaided_snr = (
8.0
- 7.0 * s["noise"]
- 3.0 * s["reverb"]
- 2.0 * s["distance"]
- 2.0 * s["competing_speech"]
- 2.5 * user.noise_difficulty
)
return {
"unaided_snr_db": float(unaided_snr),
"target_azimuth": float(s["target_azimuth"]),
"target_mobility": float(s["target_mobility"]),
"competing_speech": float(s["competing_speech"]),
"reverb": float(s["reverb"]),
"distance": float(s["distance"]),
"noise": float(s["noise"]),
"spatial_difficulty": float(np.clip(
0.55 * s["spatial"] + 0.45 * user.spatial_difficulty, 0, 1
)),
"noise_type": _scene_noise_type(scene),
}
def _psychometric_intelligibility(snr_db, hearing_loss):
"""
Logistic psychometric proxy.
This is deliberately not labelled as a clinical speech-intelligibility
function. Its purpose is to make SNR effects nonlinear: the same dB
improvement can produce different percentage-point gains depending on
starting difficulty.
"""
threshold = 1.5 + 5.0 * hearing_loss
slope = 2.6
return float(1.0 / (1.0 + np.exp(-(snr_db - threshold) / slope)))
def technology_effect(technology, scene, user):
"""
Evidence-informed layered model.
Acoustic:
potential SNR -> geometry -> reverberation -> movement -> scene type
Functional:
effective SNR -> nonlinear intelligibility -> listening access
Experience:
sound quality -> effort -> comfort/wearability/charging
Technology values are simulation priors, not commercial specifications.
"""
tech = _technology_profile(technology)
s = SCENES[scene]
state = _acoustic_scene_state(scene, user)
az = state["target_azimuth"]
alignment = _angle_alignment(az)
# Geometry: conventional directionality is strongest for frontal targets.
geometry_factor = (
0.30
+ 0.70 * (
tech["off_axis_robustness"]
+ alignment * (1.0 - tech["off_axis_robustness"])
)
)
# Spatial opportunity is greatest when there is meaningful competing sound.
spatial_opportunity = float(np.clip(
0.15
+ 0.65 * s["noise"]
+ 0.35 * s["competing_speech"]
+ 0.15 * s["spatial"],
0, 1
))
# Reverberation reduces realised spatial separation.
reverb_factor = float(np.clip(
1.0 - s["reverb"] * (1.0 - tech["reverb_robustness"]) * 0.85,
0.35, 1.0
))
mobility_factor = float(np.clip(
1.0 - s["target_mobility"] * (1.0 - tech["mobility_robustness"]) * 0.55,
0.40, 1.0
))
# DNNs are explicitly scene-dependent rather than universally superior.
noise_type_factor = 1.0
if technology == "DNN beamformer":
noise_type_factor = {
"multi_talker": 1.05,
"speech_dominant": 1.00,
"environmental": 0.92,
"low_competition": 0.65,
}[state["noise_type"]]
if technology == "Augmented listening glasses":
# Hypothetical system: spatial access is a potential strength, but
# placement/orientation and real-world coupling are uncertain.
noise_type_factor = 0.92 if state["noise_type"] == "environmental" else 1.0
snr_gain_db = (
tech["potential_snr_db"]
* spatial_opportunity
* geometry_factor
* reverb_factor
* mobility_factor
* noise_type_factor
)
# Individual spatial difficulty makes complex scenes less predictable.
snr_gain_db *= (0.92 - 0.15 * user.spatial_difficulty)
# Unaided is a true zero-processing reference.
if technology == "Unaided listening":
snr_gain_db = 0.0
effective_snr_db = state["unaided_snr_db"] + snr_gain_db
unaided_intelligibility = _psychometric_intelligibility(
state["unaided_snr_db"], user.hearing_loss
)
intelligibility = _psychometric_intelligibility(
effective_snr_db, user.hearing_loss
)
intelligibility_gain = float(
np.clip(intelligibility - unaided_intelligibility, -1, 1)
)
target_preservation = float(np.clip(
tech["target_preservation"]
- 0.12 * state["competing_speech"] * (1.0 - tech["competing_speech_robustness"])
- 0.08 * (1.0 - geometry_factor),
0, 1
))
spatial_robustness = float(np.clip(
0.35
+ 0.40 * tech["off_axis_robustness"]
+ 0.25 * tech["mobility_robustness"]
- 0.18 * state["spatial_difficulty"] * (1.0 - tech["off_axis_robustness"]),
0, 1
))
listening_access = float(np.clip(
0.55 * intelligibility
+ 0.20 * target_preservation
+ 0.15 * spatial_robustness
+ 0.10 * (1.0 - s["reverb"]),
0, 1
))
# Signal-quality trade-off: stronger processing can raise SNR while
# introducing artefact / fidelity penalties.
artefact_burden = float(np.clip(
tech["processing_intensity"]
* (0.35 + 0.65 * s["noise"])
* (1.05 - tech["naturalness"])
+ 0.08 * tech["processing_intensity"] * s["competing_speech"],
0, 1
))
sound_quality = float(np.clip(
tech["naturalness"] - artefact_burden,
0.35, 1.0
))
perceived_benefit = float(np.clip(
0.45 * listening_access
+ 0.25 * np.clip(0.50 + 1.6 * intelligibility_gain, 0, 1)
+ 0.15 * sound_quality
+ 0.15 * target_preservation,
0, 1
))
battery_burden = float(np.clip(
(1.0 - tech["battery"])
* user.battery_sensitivity
* (0.65 + 0.35 * s["noise"]),
0, 1
))
charging_burden = float(np.clip(
battery_burden * (0.65 + 0.35 * tech["complexity"]),
0, 1
))
complexity_burden = float(
tech["complexity"] * (1.0 - user.technology_tolerance)
)
comfort = float(np.clip(
0.91
- 0.16 * complexity_burden
- 0.12 * battery_burden
- 0.18 * (1.0 - tech["wearability"]) * user.comfort_sensitivity
- 0.12 * tech["visibility"] * user.visibility_sensitivity,
0.20, 0.98
))
wearability = float(np.clip(
tech["wearability"]
- 0.10 * user.comfort_sensitivity * (1.0 - tech["wearability"])
- 0.10 * tech["visibility"] * user.visibility_sensitivity,
0, 1
))
return {
"benefit": perceived_benefit,
"perceived_benefit": perceived_benefit,
"snr_gain_db": float(max(0.0, snr_gain_db)),
"effective_snr_db": float(effective_snr_db),
"unaided_snr_db": float(state["unaided_snr_db"]),
"intelligibility": float(np.clip(intelligibility, 0, 1)),
"unaided_intelligibility": float(np.clip(unaided_intelligibility, 0, 1)),
"intelligibility_gain": intelligibility_gain,
"target_preservation": target_preservation,
"spatial_robustness": spatial_robustness,
"listening_access": listening_access,
"sound_quality": sound_quality,
"artefact_burden": artefact_burden,
"comfort": comfort,
"wearability": wearability,
"battery_burden": battery_burden,
"charging_burden": charging_burden,
"complexity_burden": complexity_burden,
"target_azimuth": az,
"geometry_factor": float(geometry_factor),
"reverb_factor": float(reverb_factor),
"mobility_factor": float(mobility_factor),
"noise_type": state["noise_type"],
}
def simulate_user_day(
user,
technology,
scene,
temperature=1.0,
randomness=0.10
):
scene_data = SCENES[scene]
effects = technology_effect(technology, scene, user)
perceived_help = float(np.clip(
effects["perceived_benefit"]
* (0.65 + 0.45 * user.hearing_loss)
* (0.70 + 0.30 * user.engagement),
0, 1
))
# Effort is driven by difficulty that remains after processing.
listening_effort = float(np.clip(
0.48 * scene_data["noise"] * user.noise_difficulty
+ 0.22 * (1.0 - effects["intelligibility"])
+ 0.12 * (1.0 - effects["spatial_robustness"])
+ 0.08 * effects["artefact_burden"]
+ 0.10 * effects["complexity_burden"],
0, 1
))
fatigue = float(np.clip(
0.58 * listening_effort
+ 0.14 * (1.0 - effects["comfort"])
+ 0.12 * (1.0 - effects["wearability"])
+ 0.10 * effects["charging_burden"]
+ 0.06 * effects["artefact_burden"],
0, 1
))
social_participation = float(np.clip(
0.35
+ 0.35 * perceived_help
+ 0.20 * user.social_confidence
+ 0.15 * effects["intelligibility"]
- 0.35 * fatigue,
0, 1
))
# Daily use is behavioural: benefit helps, burden suppresses use.
use_probability = (
user.baseline_use
+ 0.22 * perceived_help
+ 0.14 * effects["comfort"]
+ 0.10 * user.technology_tolerance
- 0.16 * effects["charging_burden"]
- 0.12 * effects["complexity_burden"]
- 0.12 * user.visibility_sensitivity * (1.0 - effects["wearability"])
- 0.15 * fatigue
)
if technology == "Unaided listening":
use_probability = 1.0
use_probability = float(np.clip(
use_probability / max(temperature, 0.05), 0, 1
))
used = int(np.random.random() < use_probability)
# Approximate daily wear time. This is a behavioural simulation variable,
# not a measured distribution for a specific device.
if technology == "Unaided listening":
wear_hours = 0.0
else:
tech = _technology_profile(technology)
expected_hours = (
7.5
+ 2.0 * use_probability
+ 0.8 * perceived_help
- 1.5 * effects["charging_burden"]
- 1.0 * fatigue
)
max_hours = tech.get("charging_hours", 10.0)
wear_hours = float(np.clip(
expected_hours + np.random.normal(0, 0.65 * (1 + randomness)),
0, max_hours
))
if not used:
wear_hours *= 0.20
if not used and technology != "Unaided listening":
return {
"used": 0,
"benefit": 0.0,
"perceived_benefit": 0.0,
"snr_gain_db": effects["snr_gain_db"],
"effective_snr_db": effects["effective_snr_db"],
"unaided_snr_db": effects["unaided_snr_db"],
"intelligibility": effects["intelligibility"] * 0.65,
"unaided_intelligibility": effects["unaided_intelligibility"],
"intelligibility_gain": effects["intelligibility_gain"] * 0.65,
"target_preservation": effects["target_preservation"],
"spatial_robustness": effects["spatial_robustness"],
"listening_access": effects["listening_access"] * 0.65,
"sound_quality": effects["sound_quality"],
"artefact_burden": effects["artefact_burden"],
"comfort": effects["comfort"] * 0.85,
"wearability": effects["wearability"] * 0.85,
"battery_burden": effects["battery_burden"],
"charging_burden": effects["charging_burden"],
"complexity_burden": effects["complexity_burden"],
"listening_effort": listening_effort,
"fatigue": fatigue * 0.55,
"social_participation": social_participation * 0.55,
"listening_success": float(np.clip(0.35 * effects["intelligibility"], 0, 1)),
"acceptance_change": -0.010 * fatigue,
"wear_hours": wear_hours,
"target_azimuth": effects["target_azimuth"],
"geometry_factor": effects["geometry_factor"],
}
listening_success = float(np.clip(
0.12
+ 0.58 * effects["intelligibility"]
+ 0.18 * effects["target_preservation"]
+ 0.12 * effects["spatial_robustness"]
- 0.22 * listening_effort,
0, 1
))
acceptance_change = (
0.020 * perceived_help
+ 0.012 * effects["comfort"]
+ 0.010 * effects["wearability"]
+ 0.006 * effects["sound_quality"]
- 0.018 * fatigue
- 0.010 * effects["charging_burden"]
)
acceptance_change += np.random.normal(
0, randomness * 0.015
)
return {
"used": 1,
"benefit": perceived_help,
"perceived_benefit": perceived_help,
"snr_gain_db": effects["snr_gain_db"],
"effective_snr_db": effects["effective_snr_db"],
"unaided_snr_db": effects["unaided_snr_db"],
"intelligibility": effects["intelligibility"],
"unaided_intelligibility": effects["unaided_intelligibility"],
"intelligibility_gain": effects["intelligibility_gain"],
"target_preservation": effects["target_preservation"],
"spatial_robustness": effects["spatial_robustness"],
"listening_access": effects["listening_access"],
"sound_quality": effects["sound_quality"],
"artefact_burden": effects["artefact_burden"],
"comfort": effects["comfort"],
"wearability": effects["wearability"],
"battery_burden": effects["battery_burden"],
"charging_burden": effects["charging_burden"],
"complexity_burden": effects["complexity_burden"],
"listening_effort": listening_effort,
"fatigue": fatigue,
"social_participation": social_participation,
"listening_success": listening_success,
"acceptance_change": acceptance_change,
"wear_hours": wear_hours,
"target_azimuth": effects["target_azimuth"],
"geometry_factor": effects["geometry_factor"],
}
# ============================================================
# MAIN SIMULATION
# ============================================================
def generate_population(n_users, seed=42):
"""Create one synthetic population that can be reused across technology arms."""
np.random.seed(seed)
random.seed(seed)
return [create_user(i + 1) for i in range(int(n_users))]
def run_simulation(
n_users=20,
days=14,
technology="Adaptive directional",
scenes=None,
randomness=0.10,
engagement_weight=0.65,
social_weight=0.65,
seed=42,
users=None
):
np.random.seed(seed)
random.seed(seed)
if not scenes:
scenes = list(SCENES.keys())
if users is None:
users = generate_population(n_users, seed)
else:
users = [SyntheticUser(**vars(u)) for u in users]
records = []
for day in range(1, days + 1):
for user in users:
n_daily_scenes = min(
len(scenes),
max(2, int(len(scenes) * 0.45))
)
daily_scenes = random.sample(scenes, n_daily_scenes)
day_results = []
for scene in daily_scenes:
result = simulate_user_day(
user=user,
technology=technology,
scene=scene,
temperature=1.0 + (1.0 - engagement_weight) * 0.2,
randomness=randomness
)
user.acceptance = float(np.clip(
user.acceptance + result["acceptance_change"], 0, 1
))
user.fatigue = float(np.clip(
0.65 * user.fatigue + 0.35 * result["fatigue"], 0, 1
))
user.cumulative_benefit += result["perceived_benefit"]
day_results.append({"scene": scene, **result})
avg = pd.DataFrame(day_results).mean(numeric_only=True)
user.engagement = float(np.clip(
user.engagement
+ social_weight
* (avg["social_participation"] - 0.5)
* 0.03,
0, 1
))
records.append({
"day": day,
"user": f"User {user.user_id}",
"user_id": user.user_id,
"technology": technology,
"use": avg["used"],
"wear_hours": avg["wear_hours"],
"benefit": avg["perceived_benefit"],
"perceived_benefit": avg["perceived_benefit"],
"snr_gain_db": avg["snr_gain_db"],
"unaided_snr_db": avg["unaided_snr_db"],
"effective_snr_db": avg["effective_snr_db"],
"intelligibility": avg["intelligibility"],
"unaided_intelligibility": avg["unaided_intelligibility"],
"intelligibility_gain": avg["intelligibility_gain"],
"target_preservation": avg["target_preservation"],
"spatial_robustness": avg["spatial_robustness"],
"listening_access": avg["listening_access"],
"sound_quality": avg["sound_quality"],
"artefact_burden": avg["artefact_burden"],
"listening_effort": avg["listening_effort"],
"comfort": avg["comfort"],
"wearability": avg["wearability"],
"battery_burden": avg["battery_burden"],
"charging_burden": avg["charging_burden"],
"complexity_burden": avg["complexity_burden"],
"fatigue": avg["fatigue"],
"social_participation": avg["social_participation"],
"listening_success": avg["listening_success"],
"acceptance": user.acceptance,
"engagement": user.engagement,
"hearing_loss": user.hearing_loss,
"noise_difficulty": user.noise_difficulty,
"spatial_difficulty": user.spatial_difficulty,
"technology_tolerance": user.technology_tolerance,
"battery_sensitivity": user.battery_sensitivity,
"visibility_sensitivity": user.visibility_sensitivity,
"target_azimuth": avg["target_azimuth"],
"geometry_factor": avg["geometry_factor"],
})
return pd.DataFrame(records)
# ============================================================
# METRICS
# ============================================================
def summary_metrics(df):
return {
"use": float(df["use"].mean()),
"benefit": float(df["perceived_benefit"].mean()),
"comfort": float(df["comfort"].mean()),
"fatigue": float(df["fatigue"].mean()),
"engagement": float(df["engagement"].mean()),
"acceptance": float(df["acceptance"].mean()),
"success": float(df["listening_success"].mean()),
"social": float(df["social_participation"].mean()),
"snr_gain_db": float(df["snr_gain_db"].mean()),
"intelligibility": float(df["intelligibility"].mean()),
"intelligibility_gain": float(df["intelligibility_gain"].mean()),
"target_preservation": float(df["target_preservation"].mean()),
"spatial_robustness": float(df["spatial_robustness"].mean()),
"listening_access": float(df["listening_access"].mean()),
"wearability": float(df["wearability"].mean()),
"charging_burden": float(df["charging_burden"].mean()),
"effective_snr_db": float(df["effective_snr_db"].mean()),
"listening_effort": float(df["listening_effort"].mean()),
"sound_quality": float(df["sound_quality"].mean()),
"artefact_burden": float(df["artefact_burden"].mean()),
"wear_hours": float(df["wear_hours"].mean()),
}
def make_summary_table(df):
m = summary_metrics(df)
rows = [
("Daily condition use", m["use"], "0–1"),
("Wear time (hours/day)", m["wear_hours"], "hours"),
("Perceived benefit", m["benefit"], "0–1"),
("Comfort", m["comfort"], "0–1"),
("Listening success", m["success"], "0–1"),
("Estimated intelligibility", m["intelligibility"], "0–1"),
("Intelligibility gain vs unaided", m["intelligibility_gain"], "percentage-point proxy"),
("SNR enhancement (dB)", m["snr_gain_db"], "dB"),
("Unaided SNR proxy (dB)", float(df["unaided_snr_db"].mean()), "model proxy"),
("Effective SNR (dB)", m["effective_snr_db"], "model proxy"),
("Target preservation", m["target_preservation"], "0–1"),
("Spatial robustness", m["spatial_robustness"], "0–1"),
("Listening access", m["listening_access"], "0–1"),
("Listening effort", m["listening_effort"], "0–1; lower is better"),
("Sound quality", m["sound_quality"], "0–1"),
("Artefact burden", m["artefact_burden"], "0–1; lower is better"),
("Social participation", m["social"], "0–1"),
("Fatigue", m["fatigue"], "0–1; lower is better"),
("Engagement", m["engagement"], "0–1"),
("Acceptance", m["acceptance"], "0–1"),
("Wearability", m["wearability"], "0–1"),
("Charging burden", m["charging_burden"], "0–1; lower is better"),
]
return pd.DataFrame({
"Measure": [r[0] for r in rows],
"Score": [round(float(r[1]), 3) for r in rows],
"Interpretation": [r[2] for r in rows],
})
# ============================================================
# RADAR / BEHAVIOURAL PROFILE
# ============================================================
def radar_plot(df):
"""Create the behavioural profile radar chart used by the Overview tab.
The radar deliberately uses downstream user-experience measures rather
than mixing acoustic dB values with normalised behavioural scores.
Lower-is-better measures such as fatigue and listening effort are inverted
so that a larger radar value consistently represents a more favourable
profile.
"""
m = summary_metrics(df)
categories = [
"Use",
"Perceived benefit",
"Comfort",
"Listening success",
"Social participation",
"Acceptance",
"Engagement",
"Low fatigue",
"Low effort",
"Wearability",
]
values = [
m["use"],
m["benefit"],
m["comfort"],
m["success"],
m["social"],
m["acceptance"],
m["engagement"],
1.0 - m["fatigue"],
1.0 - m["listening_effort"],
m["wearability"],
]
# Close the polygon.
theta = categories + [categories[0]]
r = values + [values[0]]
fig = go.Figure()
fig.add_trace(
go.Scatterpolar(
r=r,
theta=theta,
fill="toself",
name=str(df["technology"].iloc[0]),
hovertemplate="%{theta}: %{r:.0%}<extra></extra>",
)
)
fig.update_layout(
title="Behavioural and user-experience profile",
polar=dict(
radialaxis=dict(
visible=True,
range=[0, 1],
tickformat=".0f",
)
),
showlegend=False,
margin=dict(l=55, r=55, t=70, b=55),
)
return fig
# ============================================================
# TREND
# ============================================================
def trend_plot(df):
grouped = (
df.groupby("day")
.agg({
"use": "mean",
"perceived_benefit": "mean",
"comfort": "mean",
"fatigue": "mean",
"engagement": "mean",
"acceptance": "mean",
"listening_success": "mean",
"intelligibility": "mean",
"listening_effort": "mean",
"wear_hours": "mean",
})
.reset_index()
)
long = grouped.melt(
id_vars="day",
var_name="Measure",
value_name="Value"
)
label_map = {
"use": "Use",
"perceived_benefit": "Perceived benefit",
"comfort": "Comfort",
"fatigue": "Fatigue",
"engagement": "Engagement",
"acceptance": "Acceptance",
"listening_success": "Listening success",
"intelligibility": "Intelligibility",
"listening_effort": "Listening effort",
"wear_hours": "Wear time (h/day)",
}
long["Measure"] = long["Measure"].map(label_map)
fig = px.line(
long, x="day", y="Value", color="Measure",
markers=True, title="Behaviour and functional outcomes over time"
)
fig.update_yaxes(range=[0, 1], title="Normalised score")
fig.update_xaxes(title="Simulation day")
return fig
# ============================================================
# SCENE ANALYSIS
# ============================================================
def scene_plot(df):
technology = df["technology"].iloc[0]
rows = []
n_users = min(100, df["user_id"].nunique())
users = generate_population(n_users, seed=123)
for scene in SCENES:
values = [
simulate_user_day(
user=user,
technology=technology,
scene=scene,
randomness=0
)
for user in users
]
temp = pd.DataFrame(values)
rows.append({
"Scene": scene,
"Listening success": temp["listening_success"].mean(),
"Intelligibility": temp["intelligibility"].mean(),
"Comfort": temp["comfort"].mean(),
"Social participation": temp["social_participation"].mean(),
"Fatigue": temp["fatigue"].mean(),
"SNR gain (normalised)": np.clip(
temp["snr_gain_db"].mean() / 10.0, 0, 1
),
})
scene_df = pd.DataFrame(rows)
fig = px.bar(
scene_df,
x="Scene",
y=[
"Listening success",
"Intelligibility",
"Comfort",
"Social participation"
],
barmode="group",
title=f"Expected experience across scenes β€” {technology}"
)
fig.update_yaxes(range=[0, 1])
return fig
# ============================================================
# INDIVIDUAL VARIABILITY
# ============================================================
def variability_plot(df):
latest = (
df.sort_values("day")
.groupby("user_id")
.tail(1)
)
fig = px.scatter(
latest,
x="noise_difficulty",
y="acceptance",
size="engagement",
hover_data=[
"user",
"hearing_loss",
"spatial_difficulty",
"technology_tolerance"
],
title="Individual variability"
)
fig.update_xaxes(
title="Noise difficulty"
)
fig.update_yaxes(
title="Technology acceptance",
range=[0, 1]
)
return fig
# ============================================================
# HEATMAP
# ============================================================
def heatmap_plot(df):
pivot = (
df.groupby(
["user", "day"]
)["perceived_benefit"]
.mean()
.unstack()
)
fig = px.imshow(
pivot,
aspect="auto",
title="Individual perceived-benefit trajectories"
)
return fig
# ============================================================
# TECHNOLOGY COMPARISON
# ============================================================
def run_comparison(
n_users,
days,
technologies,
scenes,
randomness,
seed,
reference_condition="Unaided listening"
):
"""
Paired comparison: one synthetic population is generated once and
reused across all arms. This prevents population composition from
being confounded with technology condition.
"""
if not scenes:
scenes = list(SCENES.keys())
population = generate_population(int(n_users), int(seed))
conditions = [reference_condition] + [
t for t in technologies if t != reference_condition
]
results = []
for condition in conditions:
df = run_simulation(
n_users=n_users,
days=days,
technology=condition,
scenes=scenes,
randomness=randomness,
seed=seed,
users=population
)
df["arm"] = condition
df["reference_condition"] = reference_condition
df["is_reference"] = condition == reference_condition
results.append(df)
return pd.concat(results, ignore_index=True)
def comparison_effect_table(df, reference_condition):
metric_specs = [
("Listening success", "listening_success"),
("Estimated intelligibility", "intelligibility"),
("Intelligibility gain vs unaided", "intelligibility_gain"),
("SNR enhancement (dB)", "snr_gain_db"),
("Comfort", "comfort"),
("Wearability", "wearability"),
("Listening access", "listening_access"),
("Target preservation", "target_preservation"),
("Spatial robustness", "spatial_robustness"),
("Social participation", "social_participation"),
("Fatigue", "fatigue"),
("Engagement", "engagement"),
("Acceptance", "acceptance"),
("Charging burden", "charging_burden"),
]
grouped = df.groupby("technology")
reference = grouped.mean(numeric_only=True).loc[reference_condition]
rows = []
for technology in grouped.groups:
mean_row = grouped.mean(numeric_only=True).loc[technology]
row = {
"Technology": technology,
"Reference": reference_condition,
"Same as reference": technology == reference_condition,
}
for label, col in metric_specs:
if technology == reference_condition:
row[f"{label} β€” absolute"] = round(float(mean_row[col]), 3)
row[f"{label} β€” effect"] = 0.0
else:
row[f"{label} β€” absolute"] = round(float(mean_row[col]), 3)
row[f"{label} β€” effect"] = round(
float(mean_row[col] - reference[col]), 3
)
rows.append(row)
return pd.DataFrame(rows)
def comparison_plot(df, reference_condition="Unaided listening"):
comparison = (
df.groupby("technology")
.agg({
"listening_success": "mean",
"intelligibility": "mean",
"comfort": "mean",
"social_participation": "mean",
"fatigue": "mean",
"acceptance": "mean"
})
.reset_index()
)
long = comparison.melt(
id_vars="technology",
var_name="Measure",
value_name="Value"
)
label_map = {
"listening_success": "Listening success",
"intelligibility": "Intelligibility",
"comfort": "Comfort",
"social_participation": "Social participation",
"fatigue": "Fatigue",
"acceptance": "Acceptance",
}
long["Measure"] = long["Measure"].map(label_map)
fig = px.bar(
long,
x="technology",
y="Value",
color="Measure",
barmode="group",
title=f"Technology comparison β€” reference: {reference_condition}"
)
fig.update_yaxes(range=[0, 1])
return fig
# ============================================================
# ZERO GPU LLM
# ============================================================
ANALYST_MODEL = None
ANALYST_TOKENIZER = None
@spaces.GPU
def gpu_analyst(
summary_text,
technology,
evidence_text
):
global ANALYST_MODEL
global ANALYST_TOKENIZER
try:
from transformers import (
AutoTokenizer,
AutoModelForSeq2SeqLM
)
model_name = "google/flan-t5-small"
if ANALYST_MODEL is None:
ANALYST_TOKENIZER = (
AutoTokenizer.from_pretrained(
model_name
)
)
ANALYST_MODEL = (
AutoModelForSeq2SeqLM.from_pretrained(
model_name
)
)
ANALYST_MODEL.eval()
prompt = f"""
You are a research assistant analysing a synthetic
hearing technology behaviour simulation.
Technology:
{technology}
Simulation summary:
{summary_text}
Evidence context:
{evidence_text}
Provide a concise research interpretation.
Structure your response as:
1. Simulation finding
2. Possible behavioural explanation
3. Important limitation
4. Suggested next experiment
Do not claim that these results predict real patients.
"""
inputs = ANALYST_TOKENIZER(
prompt,
return_tensors="pt",
truncation=True,
max_length=512
)
outputs = ANALYST_MODEL.generate(
**inputs,
max_new_tokens=180,
do_sample=False
)
return ANALYST_TOKENIZER.decode(
outputs[0],
skip_special_tokens=True
)
except Exception as e:
return (
"### AI analyst unavailable\n\n"
"The ZeroGPU allocation could not complete "
"the LLM analysis.\n\n"
f"Technical message: `{e}`"
)
# ============================================================
# EXPERIMENT CALLBACK
# ============================================================
def run_experiment(
n_users,
days,
technology,
scenes,
randomness,
engagement_weight,
social_weight,
seed
):
if not scenes:
scenes = list(SCENES.keys())
df = run_simulation(
n_users=int(n_users),
days=int(days),
technology=technology,
scenes=scenes,
randomness=float(randomness),
engagement_weight=float(engagement_weight),
social_weight=float(social_weight),
seed=int(seed)
)
m = summary_metrics(df)
headline = f"""
### Experiment complete
**{int(n_users)} synthetic users** Β· **{int(days)} days** Β·
**{technology}** Β· **{len(scenes)} environments**
The simulation generated **{len(df):,} user-day observations**.
**Acoustic β†’ functional β†’ behavioural chain:** mean SNR enhancement
**{m['snr_gain_db']:.1f} dB** Β· estimated intelligibility
**{m['intelligibility']:.0%}** Β· listening success
**{m['success']:.0%}**.
Perceived benefit is a downstream experience measure, not a direct
SNR or intelligibility measurement.
Results describe synthetic behaviour under the assumptions of this model.
They should not be interpreted as predictions of real users.
"""
return (
df,
df.round(3),
make_summary_table(df),
f"""<div class="kpi"><div class="kpi-label">Daily use</div>
<div class="kpi-value">{m['use']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Perceived benefit</div>
<div class="kpi-value">{m['benefit']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Comfort</div>
<div class="kpi-value">{m['comfort']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Fatigue</div>
<div class="kpi-value">{m['fatigue']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Engagement</div>
<div class="kpi-value">{m['engagement']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Acceptance</div>
<div class="kpi-value">{m['acceptance']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Listening success</div>
<div class="kpi-value">{m['success']:.0%}</div></div>""",
f"""<div class="kpi"><div class="kpi-label">Social participation</div>
<div class="kpi-value">{m['social']:.0%}</div></div>""",
radar_plot(df),
trend_plot(df),
scene_plot(df),
variability_plot(df),
heatmap_plot(df),
headline
)
# ============================================================
# COMPARISON CALLBACK
# ============================================================
def run_comparison_ui(
n_users,
days,
technologies,
scenes,
randomness,
seed,
reference_condition
):
if not technologies:
return None, None, "Select at least one experimental technology."
if not scenes:
scenes = list(SCENES.keys())
df = run_comparison(
int(n_users),
int(days),
technologies,
scenes,
float(randomness),
int(seed),
reference_condition=reference_condition
)
table = comparison_effect_table(df, reference_condition)
same = [
t for t in technologies if t == reference_condition
]
if same:
status = (
f"**Reference:** {reference_condition}. "
"The same technology is included as an arm; its technology "
"effect is fixed at 0 by definition."
)
else:
status = (
f"**Comparison:** {', '.join(technologies)} "
f"vs **{reference_condition}**. "
"Effects are paired against the same synthetic population."
)
return (
table,
comparison_plot(df, reference_condition),
status
)
# ============================================================
# AI ANALYST CALLBACK
# ============================================================
def ask_ai(
df,
technology
):
if df is None:
return (
"""
### No simulation available
Run an experiment first.
The AI analyst needs the simulation results before
it can interpret anything.
"""
)
summary = (
make_summary_table(df)
.to_string(index=False)
)
evidence = "\n".join(
f"- {v['label']}: "
f"{v['description']} "
f"({v['source']})"
for v in EVIDENCE.values()
)
return gpu_analyst(
summary,
technology,
evidence
)
# ============================================================
# CSS
# ============================================================
CSS = """
/* ============================================================
GLOBAL
============================================================ */
body {
background: #f4f7fb !important;
}
.gradio-container {
max-width: 1480px !important;
margin: auto !important;
}
/* ============================================================
HEADER
============================================================ */
.lab-header {
background:
linear-gradient(
135deg,
#0f2747 0%,
#173e68 55%,
#245b88 100%
);
border-radius: 18px;
padding: 30px 34px;
margin-bottom: 20px;
color: white;
box-shadow:
0 8px 28px
rgba(
15,
39,
71,
0.16
);
}
.lab-header h1 {
font-size: 36px !important;
font-weight: 750 !important;
color: #ffffff !important;
margin:
0 0 10px 0 !important;
line-height: 1.15 !important;
}
.lab-header p {
margin:
0 !important;
font-size: 17px !important;
font-weight: 400 !important;
color: #f4f8fc !important;
opacity: 1 !important;
max-width: 950px !important;
line-height: 1.55 !important;
}
.lab-author {
margin-top: 16px !important;
font-size: 15px !important;
font-weight: 600 !important;
color: #ffffff !important;
opacity: 1 !important;
letter-spacing: 0.01em;
}
/* ============================================================
SECTION CARDS
============================================================ */
.card {
background: white;
border:
1px solid #e1e7ef;
border-radius: 15px;
padding: 20px;
box-shadow:
0 3px 12px
rgba(
15,
39,
71,
0.05
);
}
.card-title {
font-size: 19px;
font-weight: 650;
color: #15324f;
margin-bottom: 5px;
}
.card-subtitle {
color: #65758b;
font-size: 13px;
margin-bottom: 16px;
}
/* ============================================================
KPI CARDS
============================================================ */
.kpi {
background: white;
border:
1px solid #e1e7ef;
border-radius: 14px;
padding: 16px;
min-height: 92px;
box-shadow:
0 3px 12px
rgba(
15,
39,
71,
0.04
);
}
.kpi-label {
font-size: 12px;
color: #708096;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kpi-value {
font-size: 27px;
font-weight: 700;
color: #15324f;
margin-top: 6px;
}
/* ============================================================
WORKFLOW
============================================================ */
.workflow {
display: flex;
gap: 10px;
margin:
4px 0 20px 0;
}
.workflow-step {
flex: 1;
background: white;
border:
1px solid #dfe6ee;
border-radius: 11px;
padding: 12px 15px;
}
.workflow-number {
font-size: 11px;
color: #3f6f9f;
font-weight: 700;
text-transform: uppercase;
}
.workflow-title {
font-size: 14px;
font-weight: 650;
color: #183653;
}
/* ============================================================
HELP BOX
============================================================ */
.help-box {
background: #eef5fb;
border-left:
4px solid #3974a8;
border-radius: 8px;
padding: 13px 15px;
color: #34536f;
font-size: 13px;
margin:
10px 0 16px 0;
}
/* ============================================================
AI STATUS
============================================================ */
.ai-status-card {
background: #f7fafc;
border:
1px solid #dce5ee;
border-radius: 12px;
padding: 16px 18px;
min-height: 100px;
}
.ai-status-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: #6b7c90;
margin-bottom: 5px;
}
.ai-status-main {
font-size: 17px;
font-weight: 700;
color: #173e68;
margin-bottom: 5px;
}
.ai-status-description {
font-size: 12px;
line-height: 1.45;
color: #68798c;
}
/* ============================================================
DISCLAIMER
============================================================ */
.disclaimer {
background: #f8fafc;
border:
1px solid #dce3eb;
border-radius: 10px;
padding: 12px 15px;
font-size: 12px;
color: #64748b;
}
/* ============================================================
BUTTONS
============================================================ */
.primary-button {
min-height: 48px !important;
font-size: 15px !important;
font-weight: 650 !important;
}
/* ============================================================
FOOTER
============================================================ */
.footer {
text-align: center;
color: #8290a1;
font-size: 12px;
padding:
22px 0 8px 0;
}
/* ============================================================
MOBILE
============================================================ */
@media (max-width: 800px) {
.lab-header {
padding: 24px;
}
.lab-header h1 {
font-size: 28px !important;
}
.lab-header p {
font-size: 15px !important;
}
.workflow {
flex-direction: column;
}
}
"""
# ============================================================
# USER INTERFACE
# ============================================================
with gr.Blocks(
title=APP_TITLE
) as demo:
# ========================================================
# HEADER
# ========================================================
gr.HTML(
"""
<div class="lab-header">
<h1>
Hearing Technology Behaviour Lab
</h1>
<p>
A synthetic-world laboratory for exploring how
hearing technology, everyday environments and
individual characteristics interact to shape
technology use and behavioural experience.
</p>
<div class="lab-author">
An independent research project by Jorge Mejia, PhD
</div>
</div>
"""
)
# ========================================================
# WORKFLOW
# ========================================================
gr.HTML(
"""
<div class="workflow">
<div class="workflow-step">
<div class="workflow-number">
Step 1
</div>
<div class="workflow-title">
Build your population
</div>
</div>
<div class="workflow-step">
<div class="workflow-number">
Step 2
</div>
<div class="workflow-title">
Create the listening world
</div>
</div>
<div class="workflow-step">
<div class="workflow-number">
Step 3
</div>
<div class="workflow-title">
Run & explore
</div>
</div>
</div>
"""
)
# ========================================================
# EXPERIMENT DESIGN
# ========================================================
with gr.Row():
with gr.Column(
scale=1,
elem_classes="card"
):
gr.HTML(
"""
<div class="card-title">
Experiment design
</div>
<div class="card-subtitle">
Define your virtual population and technology.
</div>
"""
)
n_users = gr.Slider(
minimum=5,
maximum=100,
value=20,
step=5,
label="Synthetic population",
info="Number of virtual users"
)
days = gr.Slider(
minimum=3,
maximum=60,
value=14,
step=1,
label="Simulation duration",
info="Number of simulated days"
)
technology = gr.Dropdown(
choices=list(
TECHNOLOGIES.keys()
),
value="Adaptive directional",
label="Primary technology"
)
gr.Markdown(
"""
**Single-arm simulation**
This experiment reports absolute acoustic, functional and
behavioural outcomes. A technology effect is only calculated
when you run a comparison with an explicit reference condition.
"""
)
gr.Markdown(
"""
**Technology hypothesis**
Technology parameters are simplified,
engineering-inspired abstractions. They are
not specifications for commercial devices.
"""
)
with gr.Accordion(
"Advanced behavioural settings",
open=False
):
randomness = gr.Slider(
0,
0.30,
value=0.10,
step=0.01,
label="Behavioural randomness",
info="Higher values increase individual variation."
)
engagement_weight = gr.Slider(
0.1,
1.0,
value=0.65,
step=0.05,
label="Engagement sensitivity"
)
social_weight = gr.Slider(
0.1,
1.0,
value=0.65,
step=0.05,
label="Social participation sensitivity"
)
seed = gr.Number(
value=42,
precision=0,
label="Random seed"
)
# ====================================================
# WORLD
# ====================================================
with gr.Column(
scale=2,
elem_classes="card"
):
gr.HTML(
"""
<div class="card-title">
Build the everyday world
</div>
<div class="card-subtitle">
Choose the situations your synthetic users
encounter during the simulation.
</div>
"""
)
scenes = gr.CheckboxGroup(
choices=list(
SCENES.keys()
),
value=list(
SCENES.keys()
),
label="Listening environments"
)
gr.HTML(
"""
<div class="help-box">
<strong>Why scenes matter</strong><br>
A technology is not experienced in a vacuum.
The same user can have a very different experience
in a quiet conversation, restaurant, meeting or
outdoor environment.
The model therefore treats context as a core part
of the simulation.
</div>
"""
)
gr.Markdown(
"""
### How the simulation works
Each synthetic user encounters a sample of the selected
environments. The model first estimates **scene-dependent
acoustic performance** β€” including SNR enhancement, target
preservation and spatial robustness β€” then translates that
into estimated intelligibility, listening success and
behavioural experience.
**Target location matters:** a directional technology can
perform differently when the target moves off-axis.
"""
)
run_button = gr.Button(
"Run behavioural simulation",
variant="primary",
elem_classes="primary-button"
)
# ========================================================
# STATUS
# ========================================================
status = gr.Markdown()
# ========================================================
# TABS
# ========================================================
with gr.Tabs():
# ====================================================
# OVERVIEW
# ====================================================
with gr.Tab("Overview"):
gr.Markdown(
"""
## Population overview
These headline measures summarise the simulated population.
Most behavioural scores are 0–1; acoustic outcomes such as SNR
enhancement are reported in dB and wear time in hours/day.
"""
)
with gr.Row():
kpi_use = gr.HTML()
kpi_benefit = gr.HTML()
kpi_comfort = gr.HTML()
kpi_fatigue = gr.HTML()
with gr.Row():
kpi_engagement = gr.HTML()
kpi_acceptance = gr.HTML()
kpi_success = gr.HTML()
kpi_social = gr.HTML()
gr.Markdown(
"### Behavioural profile"
)
with gr.Row():
radar = gr.Plot()
trend = gr.Plot()
gr.Markdown(
"""
### How to interpret this
The goal is not to identify a single "best"
technology.
Look for **trade-offs and heterogeneous responses**.
A technology may improve listening success while
increasing fatigue or reducing comfort for some
synthetic users.
"""
)
# ====================================================
# CONTEXT
# ====================================================
with gr.Tab("Context & behaviour"):
gr.Markdown(
"""
## The simulated world
Explore how the technology behaves across different
everyday contexts and how much variation exists
between individuals.
"""
)
with gr.Row():
scene_chart = gr.Plot()
variability_chart = gr.Plot()
gr.Markdown(
"### Individual benefit trajectories"
)
heatmap = gr.Plot()
# ====================================================
# POPULATION
# ====================================================
with gr.Tab("Synthetic population"):
gr.Markdown(
"""
## User-day observations
Each row represents one synthetic user on one
simulated day.
The population is deliberately heterogeneous.
Two users exposed to the same technology can behave
differently because their simulated characteristics
differ.
"""
)
population_table = gr.Dataframe(
interactive=False,
wrap=True,
max_height=520
)
gr.Markdown(
"### Population summary"
)
summary_table_output = gr.Dataframe(
interactive=False
)
# ====================================================
# COMPARISON
# ====================================================
with gr.Tab("Technology comparison"):
gr.Markdown(
"""
## Multi-arm experiment
Put different technologies into parallel synthetic
populations and compare their behavioural profiles.
This is where the application starts behaving more
like an experimental laboratory.
"""
)
reference_condition = gr.Dropdown(
choices=REFERENCE_CONDITIONS,
value="Unaided listening",
label="Reference condition",
info="The condition against which technology effects are calculated."
)
comparison_technologies = gr.CheckboxGroup(
choices=list(TECHNOLOGIES.keys()),
value=[
"Omnidirectional",
"Adaptive directional",
"Binaural beamformer",
"DNN beamformer"
],
label="Experimental technology arms",
info="Select one or more technologies to compare with the reference."
)
gr.Markdown(
"""
**Comparison logic**
`Technology effect = experimental outcome βˆ’ reference outcome`
If the same condition is selected on both sides, its technology
effect is **0 by definition**. The same synthetic population is
reused across arms so population composition is not confounded
with technology.
"""
)
comparison_button = gr.Button(
"Run technology comparison",
variant="primary"
)
comparison_status = gr.Markdown()
comparison_table_output = gr.Dataframe(
interactive=False
)
comparison_chart_output = gr.Plot()
# ====================================================
# AI ANALYST
# ====================================================
with gr.Tab("AI analyst"):
gr.Markdown(
"""
## AI research analyst
The AI analyst examines the results of your simulation
and turns them into a short research-oriented
interpretation.
**The simulation comes first. The LLM comes second.**
The LLM does not create the synthetic users or determine
their behaviour. It receives the completed simulation,
selected technology, and evidence context, then
interprets the observed patterns.
"""
)
gr.HTML(
"""
<div class="help-box">
<strong>What happens when you click the button?</strong>
<ol style="margin-top:8px; margin-bottom:4px;">
<li>
<strong>Simulation results are collected</strong>
from the experiment you just ran.
</li>
<li>
<strong>Evidence context is supplied</strong>
covering behavioural dimensions such as comfort,
listening effort, social participation and use.
</li>
<li>
<strong>A small LLM is activated on ZeroGPU</strong>
to analyse the results.
</li>
<li>
<strong>The LLM produces an interpretation</strong>
highlighting findings, possible explanations,
limitations and a suggested next experiment.
</li>
</ol>
</div>
"""
)
with gr.Row():
with gr.Column(
scale=2
):
ai_button = gr.Button(
"β–Ά Analyse this simulation with AI",
variant="primary",
elem_classes="primary-button"
)
with gr.Column(
scale=1
):
gr.HTML(
"""
<div class="ai-status-card">
<div class="ai-status-title">
AI ANALYST
</div>
<div class="ai-status-main">
Small LLM Β· ZeroGPU
</div>
<div class="ai-status-description">
Activated only when analysis is requested.
This keeps the application lightweight
on the Hugging Face free tier.
</div>
</div>
"""
)
gr.Markdown(
"""
### What the analyst looks for
The AI is asked to look beyond a single average and
consider:
- **Behavioural trade-offs** β€” for example improved
listening success versus comfort or fatigue.
- **Context effects** β€” whether experience changes
between different listening scenes.
- **Population heterogeneity** β€” whether different
synthetic users respond differently.
- **Technology implications** β€” what the pattern might
suggest for a follow-up experiment.
- **Model limitations** β€” what should not be inferred
from the simulation.
The output is deliberately framed as **research
interpretation, not clinical advice or prediction**.
"""
)
ai_output = gr.Markdown(
"""
### Waiting for an experiment
Run a simulation first.
Once results are available, click
**β€œAnalyse this simulation with AI”**.
The ZeroGPU LLM will then interpret the results.
"""
)
gr.Markdown(
"""
### Why use an LLM here?
This demonstrates a useful division of labour:
**Behaviour engine**
β†’ generates the synthetic observations
**Visual analytics**
β†’ reveals patterns and variability
**LLM**
β†’ interprets those patterns and proposes the next question
This makes the LLM an **analytical research assistant**
rather than a black-box generator of the underlying data.
"""
)
# ====================================================
# METHODS
# ====================================================
with gr.Tab("Methods & assumptions"):
gr.Markdown(
"""
## Model architecture
```text
USER
β”‚
β–Ό
EXPERIMENT DESIGN
β”‚
β”œβ”€β”€ Population
β”œβ”€β”€ Technology
└── Listening scenes
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SYNTHETIC USER MODELβ”‚
β”‚ β”‚
β”‚ hearing profile β”‚
β”‚ noise difficulty β”‚
β”‚ spatial difficulty β”‚
β”‚ comfort sensitivity β”‚
β”‚ social confidence β”‚
β”‚ technology toleranceβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ACOUSTIC / FUNCTIONALβ”‚
β”‚ ENGINE β”‚
β”‚ β”‚
β”‚ scene geometry β”‚
β”‚ SNR enhancement β”‚
β”‚ target preservation β”‚
β”‚ spatial robustness β”‚
β”‚ intelligibility β”‚
β”‚ listening effort β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ BEHAVIOUR ENGINE β”‚
β”‚ β”‚
β”‚ use β”‚
β”‚ perceived benefit β”‚
β”‚ comfort β”‚
β”‚ fatigue β”‚
β”‚ engagement β”‚
β”‚ social participationβ”‚
β”‚ acceptance β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
SIMULATED DATA
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
Trends Profiles Variability
β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
AI ANALYST
ZeroGPU
β”‚
β–Ό
Research interpretation
β”‚
β–Ό
Next experiment
```
### Evidence grounding
Behavioural dimensions are informed by themes
appearing in hearing-aid user research, including:
- benefit
- comfort
- performance in noise
- directionality
- battery burden
- social participation
- listening effort
- continued use
These are translated into explicit modelling
assumptions rather than hidden inside an opaque
machine-learning model.
### Layered outcome model
The simulation separates:
1. **Acoustic performance** β€” realised SNR enhancement,
effective SNR, target preservation and spatial robustness.
2. **Functional listening** β€” estimated intelligibility,
listening access, listening success and effort.
3. **User experience** β€” comfort, wearability, charging burden,
fatigue, engagement, acceptance and social participation.
SNR enhancement is therefore not treated as synonymous with
intelligibility or behavioural benefit. Scene geometry,
target location, competing speech and individual differences
can change the realised outcome.
All acoustic parameters are simplified engineering-inspired
assumptions and are not measurements of commercial devices.
### Important limitation
The synthetic users are **not simulated copies of
real people**.
The model is useful for:
- hypothesis generation
- exploring interactions
- demonstrating AI methods
- testing experimental designs
- visualising behavioural trade-offs
It is not appropriate for:
- clinical prediction
- individual patient recommendations
- estimating real-world prevalence
- claiming efficacy of a commercial technology
"""
)
gr.Markdown(
"### Evidence dimensions"
)
evidence_df = pd.DataFrame(
[
{
"Dimension":
value["label"],
"Role":
value["description"],
"Basis":
value["source"]
}
for value in EVIDENCE.values()
]
)
gr.Dataframe(
value=evidence_df,
interactive=False
)
# ========================================================
# DISCLAIMER
# ========================================================
gr.HTML(
f"""
<div class="disclaimer">
<strong>Research-use notice</strong><br>
{DISCLAIMER}
</div>
<div class="footer">
Hearing Technology Behaviour Lab Β·
Synthetic population modelling Β·
Architecture {APP_VERSION}
</div>
"""
)
# ========================================================
# STATE
# ========================================================
latest_results = gr.State(
value=None
)
# ========================================================
# RUN EXPERIMENT
# ========================================================
run_button.click(
fn=run_experiment,
inputs=[
n_users,
days,
technology,
scenes,
randomness,
engagement_weight,
social_weight,
seed
],
outputs=[
latest_results,
population_table,
summary_table_output,
kpi_use,
kpi_benefit,
kpi_comfort,
kpi_fatigue,
kpi_engagement,
kpi_acceptance,
kpi_success,
kpi_social,
radar,
trend,
scene_chart,
variability_chart,
heatmap,
status
]
)
# ========================================================
# TECHNOLOGY COMPARISON
# ========================================================
comparison_button.click(
fn=run_comparison_ui,
inputs=[
n_users,
days,
comparison_technologies,
scenes,
randomness,
seed,
reference_condition
],
outputs=[
comparison_table_output,
comparison_chart_output,
comparison_status
]
)
# ========================================================
# AI ANALYST
# ========================================================
ai_button.click(
fn=ask_ai,
inputs=[
latest_results,
technology
],
outputs=[
ai_output
]
)
# ============================================================
# LAUNCH
# ============================================================
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
server_port=7860,
show_error=True,
css=CSS,
theme=gr.themes.Soft(
primary_hue="blue",
secondary_hue="slate",
neutral_hue="slate"
)
)