publish eve_audio_watcher.py
This commit is contained in:
parent
aa86a1453f
commit
840e31ebac
1 changed files with 186 additions and 0 deletions
186
eve_audio_watcher.py
Normal file
186
eve_audio_watcher.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Audio watcher — catches things the game only tells you by SOUND.
|
||||||
|
|
||||||
|
A bare target-lock is NOT in any log or API, but EVE plays a distinct sound when you
|
||||||
|
get locked. This captures your Windows speaker output (WASAPI loopback) and matches it
|
||||||
|
against short sound 'fingerprints' you record once — so it can warn you "someone locked
|
||||||
|
you", "rock depleted", "warp/jump/dock", etc., that nothing else can see.
|
||||||
|
|
||||||
|
How it learns (one-time per sound, do it while that sound plays in-game):
|
||||||
|
python eve_audio_watcher.py --learn targeted # records ~2.5s, saves a fingerprint
|
||||||
|
python eve_audio_watcher.py --learn depleted
|
||||||
|
python eve_audio_watcher.py --list # show learned sounds
|
||||||
|
Then it runs headless during a mining session and alerts when it hears them.
|
||||||
|
|
||||||
|
Deps (auto-installed by update.py): numpy, soundcard. If they're missing it exits
|
||||||
|
quietly so it never breaks the rest of the watcher fleet.
|
||||||
|
|
||||||
|
python eve_audio_watcher.py # run (waits for !mining on)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
import eve_orehold_watcher as w # notify, bot_mining, bot_muted, heartbeat
|
||||||
|
|
||||||
|
SOUNDS_DIR = os.path.join(HERE, "sounds")
|
||||||
|
RATE = 48000
|
||||||
|
LEARN_SECS = 2.5
|
||||||
|
WINDOW_SECS = 1.2
|
||||||
|
BANDS = 32
|
||||||
|
MATCH_THRESHOLD = 0.86
|
||||||
|
RMS_FLOOR = 0.004 # ignore near-silence
|
||||||
|
|
||||||
|
# preset alert text for the well-known cues (used when you --learn one of these names)
|
||||||
|
PRESETS = {
|
||||||
|
"targeted": ("🔒 Targeted", "Someone just locked you — watch for tackle, be ready to warp.", "urgent"),
|
||||||
|
"depleted": ("Rock depleted", "Asteroid mined out — switch rocks.", "high"),
|
||||||
|
"warp": ("Warp", "Warp drive active.", "low"),
|
||||||
|
"jump": ("Jumped", "Gate jump.", "low"),
|
||||||
|
"dock": ("Docked", "Docked up.", "low"),
|
||||||
|
"undock": ("Undocked", "In space.", "low"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _np():
|
||||||
|
import numpy as np
|
||||||
|
return np
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(mono, rate=RATE, bands=BANDS):
|
||||||
|
"""Spectral signature: log-spaced FFT band energies, L2-normalized. Length-agnostic."""
|
||||||
|
np = _np()
|
||||||
|
x = np.asarray(mono, dtype=np.float64).ravel()
|
||||||
|
if x.size < 256:
|
||||||
|
return None
|
||||||
|
x = x - x.mean()
|
||||||
|
X = np.abs(np.fft.rfft(x * np.hanning(x.size)))
|
||||||
|
freqs = np.fft.rfftfreq(x.size, 1.0 / rate)
|
||||||
|
edges = np.logspace(np.log10(80), np.log10(rate / 2), bands + 1)
|
||||||
|
feat = np.array([X[(freqs >= edges[i]) & (freqs < edges[i + 1])].mean()
|
||||||
|
if np.any((freqs >= edges[i]) & (freqs < edges[i + 1])) else 0.0
|
||||||
|
for i in range(bands)])
|
||||||
|
k = np.array([0.5, 1.0, 0.5]) # light blur -> robust to small pitch shift
|
||||||
|
feat = np.convolve(feat, k / k.sum(), mode="same")
|
||||||
|
n = np.linalg.norm(feat)
|
||||||
|
return feat / n if n > 0 else feat
|
||||||
|
|
||||||
|
|
||||||
|
def rms(mono):
|
||||||
|
np = _np()
|
||||||
|
x = np.asarray(mono, dtype=np.float64).ravel()
|
||||||
|
return float(np.sqrt(np.mean(x * x))) if x.size else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def cosine(a, b):
|
||||||
|
return float(_np().dot(a, b)) # inputs are unit vectors
|
||||||
|
|
||||||
|
|
||||||
|
def load_templates():
|
||||||
|
out = {}
|
||||||
|
if not os.path.isdir(SOUNDS_DIR):
|
||||||
|
return out
|
||||||
|
np = _np()
|
||||||
|
for f in os.listdir(SOUNDS_DIR):
|
||||||
|
if f.endswith(".json"):
|
||||||
|
d = json.load(open(os.path.join(SOUNDS_DIR, f)))
|
||||||
|
out[d["name"]] = {**d, "vec": np.array(d["vec"])}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _recorder(secs):
|
||||||
|
"""Yield a mono numpy array of `secs` of speaker loopback."""
|
||||||
|
import soundcard as sc
|
||||||
|
np = _np()
|
||||||
|
spk = sc.default_speaker()
|
||||||
|
mic = sc.get_microphone(str(spk.name), include_loopback=True)
|
||||||
|
with mic.recorder(samplerate=RATE, channels=1) as rec:
|
||||||
|
data = rec.record(numframes=int(secs * RATE))
|
||||||
|
return np.asarray(data).ravel()
|
||||||
|
|
||||||
|
|
||||||
|
def learn(name):
|
||||||
|
os.makedirs(SOUNDS_DIR, exist_ok=True)
|
||||||
|
print(f"Recording '{name}' for {LEARN_SECS}s — make the sound play NOW...")
|
||||||
|
vec = fingerprint(_recorder(LEARN_SECS))
|
||||||
|
if vec is None:
|
||||||
|
print("Got no audio. Is something playing? Try again.")
|
||||||
|
return
|
||||||
|
title, msg, prio = PRESETS.get(name, (name.title(), f"Heard: {name}", "high"))
|
||||||
|
json.dump({"name": name, "vec": vec.tolist(), "title": title,
|
||||||
|
"message": msg, "priority": prio},
|
||||||
|
open(os.path.join(SOUNDS_DIR, f"{name}.json"), "w"))
|
||||||
|
print(f"Saved fingerprint for '{name}'. ({len(load_templates())} sound(s) learned.)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if "--list" in sys.argv:
|
||||||
|
t = load_templates()
|
||||||
|
print("learned sounds:", ", ".join(t) or "(none)")
|
||||||
|
return
|
||||||
|
if "--learn" in sys.argv:
|
||||||
|
i = sys.argv.index("--learn")
|
||||||
|
if i + 1 >= len(sys.argv):
|
||||||
|
print("usage: --learn <name> (e.g. targeted, depleted, warp, jump, dock)")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
learn(sys.argv[i + 1].lower())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"learn failed (need numpy + soundcard, and audio playing): {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
cp = w.load_config()
|
||||||
|
try:
|
||||||
|
import numpy # noqa: F401
|
||||||
|
import soundcard # noqa: F401
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] deps missing ({e}); audio watcher idle. "
|
||||||
|
"update.py installs numpy+soundcard.")
|
||||||
|
return
|
||||||
|
|
||||||
|
templates = load_templates()
|
||||||
|
if not templates:
|
||||||
|
print("[audio] no learned sounds yet — run: python eve_audio_watcher.py --learn targeted")
|
||||||
|
print(f"[audio] started; {len(templates)} sound(s) (waits for !mining on)")
|
||||||
|
last = {}
|
||||||
|
cooldown = 8
|
||||||
|
announced = False
|
||||||
|
while True:
|
||||||
|
if not w.bot_mining(cp):
|
||||||
|
time.sleep(15)
|
||||||
|
continue
|
||||||
|
if not templates:
|
||||||
|
templates = load_templates()
|
||||||
|
time.sleep(10)
|
||||||
|
continue
|
||||||
|
if not announced:
|
||||||
|
w.heartbeat(cp, "audio")
|
||||||
|
announced = True
|
||||||
|
try:
|
||||||
|
chunk = _recorder(WINDOW_SECS)
|
||||||
|
if rms(chunk) < RMS_FLOOR:
|
||||||
|
continue
|
||||||
|
vec = fingerprint(chunk)
|
||||||
|
if vec is None:
|
||||||
|
continue
|
||||||
|
best, score = None, 0.0
|
||||||
|
for name, t in templates.items():
|
||||||
|
s = cosine(vec, t["vec"])
|
||||||
|
if s > score:
|
||||||
|
best, score = name, s
|
||||||
|
if best and score >= MATCH_THRESHOLD and time.time() - last.get(best, 0) > cooldown:
|
||||||
|
last[best] = time.time()
|
||||||
|
t = templates[best]
|
||||||
|
w.notify(cp, t["title"], f"{t['message']} (heard ~{score:.0%})",
|
||||||
|
priority=t.get("priority", "high"), tags="loudspeaker")
|
||||||
|
print(f"[audio] matched {best} ({score:.2f})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] error: {e}")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue