programmed 04_training_game.py

This commit is contained in:
Bigfoggin 2026-09-11 12:10:59 +02:00
parent 1265dae0be
commit afdbf20960
2 changed files with 651 additions and 0 deletions

333
src/03_simulation_volume.py Normal file
View File

@ -0,0 +1,333 @@
import sys
import random
import numpy as np
import pygame
import sounddevice as sd
# ==================== KONFIGURATION & PARAMETER ====================
ROOM_SIZE_M = 20.0
WINDOW_SIZE = 800
PIXELS_PER_METER = WINDOW_SIZE / ROOM_SIZE_M
SAMPLE_RATE = 44100
BLOCK_SIZE = 1024
C_SOUND = 343.0
HEAD_RADIUS_M = 0.0875
MAX_MAPPED_DIST = 10.0
# Schwellenwert in Metern für die Verbindung/Fusion von Punkten zu einem Körper
CLUSTER_THRESHOLD_M = 1.8
# Musikalische Intervalle zur Unterscheidung verschiedener fused Körper
CHORD_RATIOS = [1.0, 1.2, 1.498, 1.782, 2.0, 2.4]
# ==================== PUNKTE (POINT OBJECTS) ====================
class MovingPoint:
"""Repräsentiert einen einzelnen physikalischen Punkt im Raum."""
def __init__(self, x_m, y_m):
self.x_m = x_m
self.y_m = y_m
self.vx = random.uniform(-0.025, 0.025)
self.vy = random.uniform(-0.025, 0.025)
def update_physics(self):
"""Autonome Bewegung und Kollision mit den Raumgrenzen."""
self.x_m += self.vx
self.y_m += self.vy
self.vx += random.uniform(-0.001, 0.001)
self.vy += random.uniform(-0.001, 0.001)
speed = np.sqrt(self.vx**2 + self.vy**2)
if speed > 0.04:
self.vx = (self.vx / speed) * 0.04
self.vy = (self.vy / speed) * 0.04
half_r = ROOM_SIZE_M / 2.0 - 0.5
if abs(self.x_m) > half_r:
self.vx *= -1.0
self.x_m = np.clip(self.x_m, -half_r, half_r)
if abs(self.y_m) > half_r:
self.vy *= -1.0
self.y_m = np.clip(self.y_m, -half_r, half_r)
# ==================== FUSED BODY (ZUSAMMENGESETZTER KÖRPER) ====================
class FusedBody:
"""Repräsentiert einen dynamischen Körper (1 oder mehrere verschmolzene Punkte)."""
def __init__(self, points, base_ratio=1.0):
self.points = points
self.base_ratio = base_ratio
self.closest_point = None
self.min_dist_m = 999.0
self.target_freq = 220.0
self.target_itd_samples = 0.0
self.target_gain_l = 0.0
self.target_gain_r = 0.0
# Interne Audio-States für stufenlose Übergänge
self.current_freq = 220.0
self.current_itd = 0.0
self.phase_1 = random.uniform(0, 2 * np.pi)
self.phase_2 = random.uniform(0, 2 * np.pi)
def compute_spatial_params(self):
"""Ermittelt den nächsten Punkt zum Nutzer und berechnet Frequenz sowie Azimut."""
if not self.points:
return
self.closest_point = min(
self.points,
key=lambda p: np.sqrt(p.x_m**2 + p.y_m**2)
)
self.min_dist_m = np.sqrt(self.closest_point.x_m**2 + self.closest_point.y_m**2)
clamped_dist = min(self.min_dist_m, MAX_MAPPED_DIST)
norm_dist = clamped_dist / MAX_MAPPED_DIST
# Frequenz-Mapping (nah = hoch, fern = tief)
base_f_near = 880.0 * self.base_ratio
base_f_far = 220.0 * self.base_ratio
self.target_freq = base_f_near * ((base_f_far / base_f_near) ** norm_dist)
# Azimut bezogen auf den nächsten Punkt
azimuth = np.arctan2(self.closest_point.x_m, self.closest_point.y_m)
# ITD Berechnung
itd_sec = (HEAD_RADIUS_M / C_SOUND) * (np.sin(azimuth) + azimuth)
self.target_itd_samples = itd_sec * SAMPLE_RATE
self.azimuth = azimuth
def apply_relative_volume(self, global_min_dist):
"""
Berechnet die Lautstärke relativ zum nahesten Objekt im gesamten Raum.
- Das nächste Objekt (Delta = 0m) erhält 100% der Basis-Lautstärke.
- Weiter entfernte Objekte werden proportional zur Distanzdifferenz leiser.
"""
pan = np.sin(self.azimuth)
# Basis-Lautstärke des nahesten Objekts
base_master_vol = 0.25
# Relativer Dämpfungsfaktor basierend auf der Differenz zum nahesten Objekt
dist_delta = self.min_dist_m - global_min_dist
# Abfall-Intensität: Bei 5m Zusatzabstand sinkt die Lautstärke auf ~15%
rel_attenuation = np.exp(-0.4 * max(0.0, dist_delta))
effective_vol = base_master_vol * rel_attenuation
self.target_gain_l = np.clip(0.5 * (1.0 - pan), 0.02, 1.0) * effective_vol
self.target_gain_r = np.clip(0.5 * (1.0 + pan), 0.02, 1.0) * effective_vol
# ==================== GLOBALE VARIABLEN & CLUSTER-LOGIK ====================
points_list = []
fused_bodies = []
def update_clusters():
"""Identifiziert Punkte-Cluster, fusioniert sie und berechnet relative Lautstärken."""
global fused_bodies
n = len(points_list)
if n == 0:
fused_bodies = []
return
# 1. Graph-Cluster-Erkennung
visited = [False] * n
clusters = []
for i in range(n):
if not visited[i]:
cluster = []
queue = [i]
visited[i] = True
while queue:
curr = queue.pop(0)
cluster.append(points_list[curr])
for neighbor in range(n):
if not visited[neighbor]:
dx = points_list[curr].x_m - points_list[neighbor].x_m
dy = points_list[curr].y_m - points_list[neighbor].y_m
dist = np.sqrt(dx**2 + dy**2)
if dist <= CLUSTER_THRESHOLD_M:
visited[neighbor] = True
queue.append(neighbor)
clusters.append(cluster)
# 2. FusedBody Instanzen erstellen / Parameter berechnen
new_fused_bodies = []
for idx, cluster_points in enumerate(clusters):
ratio = CHORD_RATIOS[idx % len(CHORD_RATIOS)]
body = FusedBody(cluster_points, base_ratio=ratio)
body.compute_spatial_params()
new_fused_bodies.append(body)
# 3. Globale euklidische Minimaldistanz ermitteln
global_min_dist = min(b.min_dist_m for b in new_fused_bodies)
# 4. Lautstärke jedes Objekts relativ zum nahesten Objekt anpassen
for body in new_fused_bodies:
body.apply_relative_volume(global_min_dist)
fused_bodies = new_fused_bodies
# ==================== AUDIO CALLBACK ====================
def audio_callback(outdata, frames, time_info, status):
if status:
print(status, file=sys.stderr)
outdata.fill(0.0)
if not fused_bodies:
return
t_indices = np.arange(frames)
for body in list(fused_bodies):
freq_vec = np.linspace(body.current_freq, body.target_freq, frames)
itd_vec = np.linspace(body.current_itd, body.target_itd_samples, frames)
body.current_freq = body.target_freq
body.current_itd = body.target_itd_samples
dphase_1 = 2 * np.pi * freq_vec / SAMPLE_RATE
dphase_2 = 2 * np.pi * (freq_vec * 1.498) / SAMPLE_RATE
phases_1 = body.phase_1 + np.cumsum(dphase_1)
phases_2 = body.phase_2 + np.cumsum(dphase_2)
body.phase_1 = phases_1[-1] % (2 * np.pi)
body.phase_2 = phases_2[-1] % (2 * np.pi)
wave_1 = np.sin(phases_1)
wave_2 = 0.25 * np.sin(phases_2)
raw_signal = 0.18 * (wave_1 + wave_2)
idx_l = t_indices + (itd_vec / 2.0)
idx_r = t_indices - (itd_vec / 2.0)
sig_l = np.interp(idx_l, t_indices, raw_signal) * body.target_gain_l
sig_r = np.interp(idx_r, t_indices, raw_signal) * body.target_gain_r
outdata[:, 0] += sig_l
outdata[:, 1] += sig_r
# ==================== HAUPTPROGRAMM ====================
def main():
pygame.init()
screen = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE))
pygame.display.set_caption("Simulation 02: Dynamic Fusion & Relative Volume Attenuation")
clock = pygame.time.Clock()
stream = sd.OutputStream(
channels=2,
samplerate=SAMPLE_RATE,
blocksize=BLOCK_SIZE,
callback=audio_callback
)
with stream:
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_c:
points_list.clear()
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1:
m_px, m_py = event.pos
x_m = (m_px - WINDOW_SIZE / 2.0) / PIXELS_PER_METER
y_m = (WINDOW_SIZE / 2.0 - m_py) / PIXELS_PER_METER
points_list.append(MovingPoint(x_m, y_m))
# 1. Physik aktualisieren
for pt in points_list:
pt.update_physics()
# 2. Cluster und relative Lautstärken berechnen
update_clusters()
# --- RENDERING ---
screen.fill((15, 18, 25))
center_px = WINDOW_SIZE // 2
# Raster & Abstandskreise
pygame.draw.line(screen, (35, 40, 55), (0, center_px), (WINDOW_SIZE, center_px), 1)
pygame.draw.line(screen, (35, 40, 55), (center_px, 0), (center_px, WINDOW_SIZE), 1)
for r_m in range(2, 11, 2):
r_px = int(r_m * PIXELS_PER_METER)
pygame.draw.circle(screen, (30, 35, 50), (center_px, center_px), r_px, 1)
# Nutzer-Kopf
head_radius_px = int(HEAD_RADIUS_M * 3 * PIXELS_PER_METER)
pygame.draw.circle(screen, (180, 190, 200), (center_px, center_px), head_radius_px)
pygame.draw.polygon(screen, (230, 90, 90), [
(center_px - 8, center_px - head_radius_px),
(center_px + 8, center_px - head_radius_px),
(center_px, center_px - head_radius_px - 12)
])
# Rote Verbindungsstriche und Richtungsvektoren
for body in fused_bodies:
pts = body.points
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
dx = pts[i].x_m - pts[j].x_m
dy = pts[i].y_m - pts[j].y_m
if np.sqrt(dx**2 + dy**2) <= CLUSTER_THRESHOLD_M:
px1 = int(center_px + pts[i].x_m * PIXELS_PER_METER)
py1 = int(center_px - pts[i].y_m * PIXELS_PER_METER)
px2 = int(center_px + pts[j].x_m * PIXELS_PER_METER)
py2 = int(center_px - pts[j].y_m * PIXELS_PER_METER)
pygame.draw.line(screen, (240, 60, 60), (px1, py1), (px2, py2), 3)
if body.closest_point:
cp_px = int(center_px + body.closest_point.x_m * PIXELS_PER_METER)
cp_py = int(center_px - body.closest_point.y_m * PIXELS_PER_METER)
# Hauptobjekt erhält hellen Vektor, entferntere Objekte gedämpfte Vektoren
pygame.draw.line(screen, (80, 220, 160, 80), (center_px, center_px), (cp_px, cp_py), 1)
# Punkte zeichnen
for pt in points_list:
px = int(center_px + pt.x_m * PIXELS_PER_METER)
py = int(center_px - pt.y_m * PIXELS_PER_METER)
pygame.draw.circle(screen, (100, 255, 180), (px, py), 6)
# HUD
font = pygame.font.SysFont("Consolas", 15)
hud_info = [
f"Punkte gesamt : {len(points_list)}",
f"Aktive Körper : {len(fused_bodies)} (Relative Dämpfung aktiv)",
"[ Links-Klick ] : Punkt droppen",
"[ Taste 'C' ] : Alle Punkte löschen",
"[ ESC ] : Beenden"
]
for idx, text in enumerate(hud_info):
txt_surface = font.render(text, True, (200, 200, 210))
screen.blit(txt_surface, (15, 15 + idx * 20))
pygame.display.flip()
clock.tick(60)
pygame.quit()
if __name__ == "__main__":
main()

318
src/04_training_game.py Normal file
View File

@ -0,0 +1,318 @@
import sys
import random
import time
import numpy as np
import pygame
import sounddevice as sd
# ==================== KONFIGURATION & PARAMETER ====================
ROOM_SIZE_M = 20.0
WINDOW_SIZE = 800
PIXELS_PER_METER = WINDOW_SIZE / ROOM_SIZE_M
SAMPLE_RATE = 44100
BLOCK_SIZE = 1024
C_SOUND = 343.0
HEAD_RADIUS_M = 0.0875
MAX_MAPPED_DIST = 10.0
FREQ_MIN_DIST = 880.0
FREQ_MAX_DIST = 220.0
REVEAL_DURATION = 2.0 # Sekunden Anzeige der Einzelauswertung
TOTAL_ITERATIONS = 5 # Runden bis zur Metrik-Auswertung
# ==================== TRAININGS-OBJEKT ====================
class TargetObject:
"""Repräsentiert das unsichtbare Ziel-Objekt im Trainingsmodus."""
def __init__(self):
self.reset()
def reset(self):
self.x_m = random.uniform(-8.0, 8.0)
self.y_m = random.uniform(-8.0, 8.0)
self.vx = random.uniform(-0.02, 0.02)
self.vy = random.uniform(-0.02, 0.02)
self.target_freq = 220.0
self.target_itd_samples = 0.0
self.target_gain_l = 0.2
self.target_gain_r = 0.2
self.current_freq = 220.0
self.current_itd = 0.0
self.phase_1 = 0.0
self.phase_2 = 0.0
def update_physics(self):
"""Bewegt das Objekt autonom im Raum."""
self.x_m += self.vx
self.y_m += self.vy
self.vx += random.uniform(-0.001, 0.001)
self.vy += random.uniform(-0.001, 0.001)
speed = np.sqrt(self.vx**2 + self.vy**2)
if speed > 0.035:
self.vx = (self.vx / speed) * 0.035
self.vy = (self.vy / speed) * 0.035
half_r = ROOM_SIZE_M / 2.0 - 0.5
if abs(self.x_m) > half_r:
self.vx *= -1.0
self.x_m = np.clip(self.x_m, -half_r, half_r)
if abs(self.y_m) > half_r:
self.vy *= -1.0
self.y_m = np.clip(self.y_m, -half_r, half_r)
def update_audio_params(self):
"""Berechnet Frequenz, ITD und ILD basierend auf der aktuellen Position."""
dist_m = np.sqrt(self.x_m**2 + self.y_m**2)
clamped_dist = min(dist_m, MAX_MAPPED_DIST)
norm_dist = clamped_dist / MAX_MAPPED_DIST
self.target_freq = FREQ_MIN_DIST * ((FREQ_MAX_DIST / FREQ_MIN_DIST) ** norm_dist)
azimuth = np.arctan2(self.x_m, self.y_m)
itd_sec = (HEAD_RADIUS_M / C_SOUND) * (np.sin(azimuth) + azimuth)
self.target_itd_samples = itd_sec * SAMPLE_RATE
pan = np.sin(azimuth)
master_vol = 0.25
self.target_gain_l = np.clip(0.5 * (1.0 - pan), 0.05, 1.0) * master_vol
self.target_gain_r = np.clip(0.5 * (1.0 + pan), 0.05, 1.0) * master_vol
target_obj = TargetObject()
# ==================== AUDIO CALLBACK ====================
def audio_callback(outdata, frames, time_info, status):
if status:
print(status, file=sys.stderr)
t_indices = np.arange(frames)
freq_vec = np.linspace(target_obj.current_freq, target_obj.target_freq, frames)
itd_vec = np.linspace(target_obj.current_itd, target_obj.target_itd_samples, frames)
target_obj.current_freq = target_obj.target_freq
target_obj.current_itd = target_obj.target_itd_samples
dphase_1 = 2 * np.pi * freq_vec / SAMPLE_RATE
dphase_2 = 2 * np.pi * (freq_vec * 1.498) / SAMPLE_RATE
phases_1 = target_obj.phase_1 + np.cumsum(dphase_1)
phases_2 = target_obj.phase_2 + np.cumsum(dphase_2)
target_obj.phase_1 = phases_1[-1] % (2 * np.pi)
target_obj.phase_2 = phases_2[-1] % (2 * np.pi)
wave_1 = np.sin(phases_1)
wave_2 = 0.25 * np.sin(phases_2)
raw_signal = 0.2 * (wave_1 + wave_2)
idx_l = t_indices + (itd_vec / 2.0)
idx_r = t_indices - (itd_vec / 2.0)
outdata[:, 0] = np.interp(idx_l, t_indices, raw_signal) * target_obj.target_gain_l
outdata[:, 1] = np.interp(idx_r, t_indices, raw_signal) * target_obj.target_gain_r
# ==================== HAUPTPROGRAMM ====================
def main():
pygame.init()
screen = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE))
pygame.display.set_caption("Simulation 03: Training Mode with Metrics Evaluation")
clock = pygame.time.Clock()
stream = sd.OutputStream(
channels=2,
samplerate=SAMPLE_RATE,
blocksize=BLOCK_SIZE,
callback=audio_callback
)
# Versuchs-Datenstrukturen
trials_data = [] # Liste von Dicts mit Daten jeder Runde
iteration = 0
round_start_time = time.time()
click_result = None
reveal_start_time = 0.0
is_revealed = False
show_summary_screen = False
with stream:
running = True
while running:
current_time = time.time()
# Timer für Übergang zwischen Runden
if is_revealed and (current_time - reveal_start_time > REVEAL_DURATION):
is_revealed = False
click_result = None
if iteration >= TOTAL_ITERATIONS:
show_summary_screen = True
else:
target_obj.reset()
round_start_time = time.time()
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_SPACE and show_summary_screen:
# Testreihe zurücksetzen für nächste 5 Runden
trials_data.clear()
iteration = 0
show_summary_screen = False
target_obj.reset()
round_start_time = time.time()
elif event.type == pygame.MOUSEBUTTONDOWN and not is_revealed and not show_summary_screen:
if event.button == 1:
decision_time = current_time - round_start_time
m_px, m_py = event.pos
click_x_m = (m_px - WINDOW_SIZE / 2.0) / PIXELS_PER_METER
click_y_m = (WINDOW_SIZE / 2.0 - m_py) / PIXELS_PER_METER
target_x_m = target_obj.x_m
target_y_m = target_obj.y_m
err_x = abs(click_x_m - target_x_m)
err_y = abs(click_y_m - target_y_m)
dist_error = np.sqrt((click_x_m - target_x_m)**2 + (click_y_m - target_y_m)**2)
iteration += 1
trial_info = {
"round": iteration,
"click_x": click_x_m,
"click_y": click_y_m,
"target_x": target_x_m,
"target_y": target_y_m,
"err_x": err_x,
"err_y": err_y,
"total_err": dist_error,
"decision_time": decision_time
}
trials_data.append(trial_info)
click_result = trial_info
is_revealed = True
reveal_start_time = current_time
# Objekt nur bewegen, wenn active Phase
if not is_revealed and not show_summary_screen:
target_obj.update_physics()
target_obj.update_audio_params()
# --- RENDERING ---
screen.fill((15, 18, 25))
center_px = WINDOW_SIZE // 2
# Raster & Abstandskreise
pygame.draw.line(screen, (35, 40, 55), (0, center_px), (WINDOW_SIZE, center_px), 1)
pygame.draw.line(screen, (35, 40, 55), (center_px, 0), (center_px, WINDOW_SIZE), 1)
for r_m in range(2, 11, 2):
r_px = int(r_m * PIXELS_PER_METER)
pygame.draw.circle(screen, (30, 35, 50), (center_px, center_px), r_px, 1)
# Nutzer-Kopf
head_radius_px = int(HEAD_RADIUS_M * 3 * PIXELS_PER_METER)
pygame.draw.circle(screen, (180, 190, 200), (center_px, center_px), head_radius_px)
pygame.draw.polygon(screen, (230, 90, 90), [
(center_px - 8, center_px - head_radius_px),
(center_px + 8, center_px - head_radius_px),
(center_px, center_px - head_radius_px - 12)
])
# 1. Einzel-Runden Reveal Visualisierung
if is_revealed and click_result is not None:
t_px = int(center_px + click_result["target_x"] * PIXELS_PER_METER)
t_py = int(center_px - click_result["target_y"] * PIXELS_PER_METER)
c_px = int(center_px + click_result["click_x"] * PIXELS_PER_METER)
c_py = int(center_px - click_result["click_y"] * PIXELS_PER_METER)
pygame.draw.line(screen, (255, 200, 80), (c_px, c_py), (t_px, t_py), 2)
pygame.draw.circle(screen, (80, 220, 160), (t_px, t_py), 10)
cross_size = 8
pygame.draw.line(screen, (240, 70, 70), (c_px - cross_size, c_py - cross_size), (c_px + cross_size, c_py + cross_size), 3)
pygame.draw.line(screen, (240, 70, 70), (c_px - cross_size, c_py + cross_size), (c_px + cross_size, c_py - cross_size), 3)
# 2. FINALES ERGEBNIS-PANEL (NACH 5 ITERATIONEN)
if show_summary_screen:
# Transparenter Overlay-Hintergrund
overlay = pygame.Surface((WINDOW_SIZE, WINDOW_SIZE))
overlay.set_alpha(220)
overlay.fill((10, 12, 18))
screen.blit(overlay, (0, 0))
# Metriken berechnen
mean_err_x = np.mean([t["err_x"] for t in trials_data])
mean_err_y = np.mean([t["err_y"] for t in trials_data])
mean_total_err = np.mean([t["total_err"] for t in trials_data])
mean_time = np.mean([t["decision_time"] for t in trials_data])
font_title = pygame.font.SysFont("Consolas", 22, bold=True)
font_body = pygame.font.SysFont("Consolas", 16)
font_highlight = pygame.font.SysFont("Consolas", 17, bold=True)
title_surf = font_title.render("--- EVALUATION ERGEBNISSE (5 ITERATIONEN) ---", True, (80, 220, 160))
screen.blit(title_surf, (100, 120))
metrics_display = [
(f"Mean Fehler X-Achse : {mean_err_x:.3f} m", (220, 220, 230)),
(f"Mean Fehler Y-Achse : {mean_err_y:.3f} m", (220, 220, 230)),
(f"Mean Euklid. Distanz : {mean_total_err:.3f} m", (255, 200, 80)),
(f"Gebrauchte Zeit / Item : {mean_time:.2f} Sekunden", (100, 200, 255)),
]
for idx, (text, color) in enumerate(metrics_display):
txt_surf = font_body.render(text, True, color)
screen.blit(txt_surf, (120, 180 + idx * 30))
# Einzelübersicht der 5 Runden
y_offset = 330
header_surf = font_highlight.render("Detailübersicht der Runden:", True, (180, 190, 200))
screen.blit(header_surf, (120, y_offset))
for t in trials_data:
y_offset += 25
row_txt = f"Runde {t['round']}: Fehler = {t['total_err']:.2f} m (X: {t['err_x']:.2f}m, Y: {t['err_y']:.2f}m) | Zeit: {t['decision_time']:.2f}s"
row_surf = font_body.render(row_txt, True, (160, 170, 185))
screen.blit(row_surf, (120, y_offset))
footer_surf = font_highlight.render("[ PRESS SPACE ] Nächste Testreihe starten | [ ESC ] Beenden", True, (80, 220, 160))
screen.blit(footer_surf, (100, 680))
else:
# HUD im aktiven Modus
font = pygame.font.SysFont("Consolas", 15)
hud_info = [
f"--- TRAININGSMODUS (Runde {iteration + 1} / {TOTAL_ITERATIONS}) ---",
"Klicke auf die vermutete Position des Objekts!",
f"Verstreichende Zeit: {current_time - round_start_time:.1f} s" if not is_revealed else "Auflösung läuft..."
]
for idx, text in enumerate(hud_info):
txt_surface = font.render(text, True, (200, 200, 210))
screen.blit(txt_surface, (15, 15 + idx * 20))
pygame.display.flip()
clock.tick(60)
pygame.quit()
if __name__ == "__main__":
main()