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%}", ) ) 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"""
Daily use
{m['use']:.0%}
""", f"""
Perceived benefit
{m['benefit']:.0%}
""", f"""
Comfort
{m['comfort']:.0%}
""", f"""
Fatigue
{m['fatigue']:.0%}
""", f"""
Engagement
{m['engagement']:.0%}
""", f"""
Acceptance
{m['acceptance']:.0%}
""", f"""
Listening success
{m['success']:.0%}
""", f"""
Social participation
{m['social']:.0%}
""", 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( """

Hearing Technology Behaviour Lab

A synthetic-world laboratory for exploring how hearing technology, everyday environments and individual characteristics interact to shape technology use and behavioural experience.

An independent research project by Jorge Mejia, PhD
""" ) # ======================================================== # WORKFLOW # ======================================================== gr.HTML( """
Step 1
Build your population
Step 2
Create the listening world
Step 3
Run & explore
""" ) # ======================================================== # EXPERIMENT DESIGN # ======================================================== with gr.Row(): with gr.Column( scale=1, elem_classes="card" ): gr.HTML( """
Experiment design
Define your virtual population and technology.
""" ) 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( """
Build the everyday world
Choose the situations your synthetic users encounter during the simulation.
""" ) scenes = gr.CheckboxGroup( choices=list( SCENES.keys() ), value=list( SCENES.keys() ), label="Listening environments" ) gr.HTML( """
Why scenes matter
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.
""" ) 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( """
What happens when you click the button?
  1. Simulation results are collected from the experiment you just ran.
  2. Evidence context is supplied covering behavioural dimensions such as comfort, listening effort, social participation and use.
  3. A small LLM is activated on ZeroGPU to analyse the results.
  4. The LLM produces an interpretation highlighting findings, possible explanations, limitations and a suggested next experiment.
""" ) 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( """
AI ANALYST
Small LLM · ZeroGPU
Activated only when analysis is requested. This keeps the application lightweight on the Hugging Face free tier.
""" ) 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"""
Research-use notice
{DISCLAIMER}
""" ) # ======================================================== # 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" ) )