diff --git a/program/MapAudio.cs b/program/MapAudio.cs new file mode 100644 index 0000000..4150f7b --- /dev/null +++ b/program/MapAudio.cs @@ -0,0 +1,105 @@ +using System; +using System.Runtime.InteropServices; + +/// Form2.AnalyzeColorScheme, ported to Gdk.Pixbuf. +public static class MapAudio +{ + public const int MIN_INTENSITY = 50; + public const int DOMINANCE_THRESHOLD = 30; + public const int MIN_PURITY = 40; + public const int BLACK_THRESHOLD = 40; + public const int BLACK_PIXEL_CONSTANT_VOLUME = 51; // 20% of 255 + + public enum Region { None, Black, Red, Blue } + + public class Result + { + public Region Region; + public bool Play; + public int Level; // 0..255, feeds AudioEngine.VolumeCurve + public byte R, G, B, A; + } + + /// Reads one pixel out of a pixbuf. False if out of bounds. + public static bool ReadPixel(Gdk.Pixbuf pb, int x, int y, + out byte r, out byte g, out byte b, out byte a) + { + r = g = b = 0; a = 255; + if (pb == null) return false; + if (x < 0 || y < 0 || x >= pb.Width || y >= pb.Height) return false; + + int n = pb.NChannels; + IntPtr p = pb.Pixels + y * pb.Rowstride + x * n; + + r = Marshal.ReadByte(p, 0); + g = Marshal.ReadByte(p, 1); + b = Marshal.ReadByte(p, 2); + a = (n >= 4) ? Marshal.ReadByte(p, 3) : (byte)255; + return true; + } + + public static Result Analyze(Gdk.Pixbuf map, int px, int py, bool blackAudioEnabled) + { + var res = new Result { Region = Region.None, Play = false, Level = 0 }; + + byte r, g, b, a; + if (!ReadPixel(map, px, py, out r, out g, out b, out a)) + return res; + + res.R = r; res.G = g; res.B = b; res.A = a; + + int max = Math.Max(Math.Max(r, g), b); + int min = Math.Min(Math.Min(r, g), b); + int purity = max - min; + + // LoadMap keys out black with AddAlpha, so background arrives as + // alpha 0, not RGB(0,0,0). This test has to come first or every + // background hit reads as a valid colour. + bool isBackground = a < 128 || max < BLACK_THRESHOLD; + + if (isBackground) + { + res.Region = Region.Black; + if (blackAudioEnabled) + { + res.Play = true; + res.Level = BLACK_PIXEL_CONSTANT_VOLUME; + } + return res; + } + + // RED — heart territory + if (r >= MIN_INTENSITY && + r > g + DOMINANCE_THRESHOLD && + r > b + DOMINANCE_THRESHOLD && + purity >= MIN_PURITY) + { + res.Region = Region.Red; + res.Play = true; + res.Level = LevelFrom(r); + return res; + } + + // BLUE — breath territory + if (b >= MIN_INTENSITY && + b > r + DOMINANCE_THRESHOLD && + b > g + DOMINANCE_THRESHOLD && + purity >= MIN_PURITY) + { + res.Region = Region.Blue; + res.Play = true; + res.Level = LevelFrom(b); + return res; + } + + return res; // unassigned colour -> silence + } + + /// Form2: intensity 50..255 maps to volume level 80..255. + private static int LevelFrom(int intensity) + { + float t = (intensity - MIN_INTENSITY) / (255.0f - MIN_INTENSITY); + int level = (int)(80 + t * 175); + return Math.Max(80, Math.Min(255, level)); + } +} \ No newline at end of file diff --git a/program/MapWindow.cs b/program/MapWindow.cs index cd4b508..328052f 100644 --- a/program/MapWindow.cs +++ b/program/MapWindow.cs @@ -119,6 +119,18 @@ private readonly Language _lang; // private readonly SoundLooper _sound = new SoundLooper(); private readonly AudioEngine _sound = new AudioEngine(); + private AudioEngine.Voice _caseVoice, _heart1, _heart2, _breath; + // Form2's addHeartSoundcheckBox1 / addBreathSoundcheckbox. + // No glade widgets for these yet — flip them here to test. + public bool HeartSoundEnabled = false; + public bool BreathSoundEnabled = false; + + private int _lastPx = -1, _lastPy = -1; + private const int TAG_IDLE_MS = 250; // Form2's noDataIntervalMs + private const int WATCHDOG_MS = 100; + + private DateTime _lastPosLine = DateTime.MinValue; + private uint _watchdogId; // ── state ── private Gdk.Pixbuf _body; // background, native size @@ -140,6 +152,7 @@ RegexOptions.Compiled); private string _bodyPath = "(none)"; private string _mapPath = "(none)"; + public event EventHandler BackRequested; @@ -247,6 +260,14 @@ HookView(ViewLeftRadio, "left"); HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw(); + if (BlackAudioCheck != null) + BlackAudioCheck.Toggled += (s, e) => + { + _lastPx = _lastPy = -1; // force re-analysis of the current pixel + UpdateAudioFromPosition(); + Logger.Write("audio", "black-pixel audio " + + (BlackAudioEnabled ? "ON" : "OFF")); + }; // Ctrl+T anywhere, or Ctrl+double-click on the map, opens the tuner. MapArea.Realized += (s, e) => HookKeys(); @@ -259,9 +280,12 @@ }; _sound.Log += msg => Logger.Write("sound", msg); - _sound.Init(); + if (!_sound.Init()) + Logger.Write("sound", "OpenAL init failed — session will be silent"); + // _sound.Init(); // NEW — live position feed _arduino.LineReceived += OnArduinoLine; + _watchdogId = GLib.Timeout.Add(WATCHDOG_MS, OnTagWatchdog); _arduino.ConnectionChanged += OnArduinoConnectionChanged; } @@ -344,7 +368,18 @@ } } - if (!havePosition) return; // status/mode line, not a coordinate line + // if (!havePosition) return; // status/mode line, not a coordinate line + if (!havePosition) + { + // Reader explicitly says there's nothing there -> clear now, don't + // wait out the watchdog. Heartbeats and mode echoes fall through and + // let the watchdog decide, so they can't chop the audio mid-touch. + if (line.Contains("All tags cleared") || line.Contains("Tags detected: 0")) + ClearPosition(); + return; + } + + _lastPosLine = DateTime.Now; // NEW — feeds the watchdog _currentX = x; _currentY = y; @@ -356,6 +391,7 @@ SwitchViewFromArduino(newView); MapArea.QueueDraw(); + UpdateAudioFromPosition(); } private static float ParseF(string s) @@ -368,10 +404,85 @@ if (!connected) { _hasPosition = false; + MuteVoices(); MapArea.QueueDraw(); } } + private void UpdateAudioFromPosition() + { + if (_caseVoice == null) return; + + double bx, by; + if (!_hasPosition || !TryGetCursorBodyPoint(out bx, out by)) + { + MuteVoices(); + return; + } + + int px, py; + if (!BodyToMapPixel(bx, by, out px, out py)) + { + MuteVoices(); // probe is off the map rectangle + return; + } + + // Serial lines arrive far faster than the tag moves a whole pixel. + if (px == _lastPx && py == _lastPy) return; + _lastPx = px; _lastPy = py; + + var res = MapAudio.Analyze(AudioSourcePixbuf, px, py, BlackAudioEnabled); + + Logger.Write("audio", "map(" + px + "," + py + ") rgba(" + + res.R + "," + res.G + "," + res.B + "," + res.A + ") -> " + + res.Region + " level=" + res.Level); + + if (!res.Play) { MuteVoices(); return; } + + ApplyLevel(res.Level); + } + + /// Form2's AdjustAudioVolumes — same /3 and /4 divisors, so the mix + /// matches the Windows build. + private void ApplyLevel(int level) + { + _sound.SetGain(_caseVoice, AudioEngine.VolumeCurve(level)); + + if (_heart1 != null) _sound.SetGain(_heart1, AudioEngine.VolumeCurve(level / 3)); + if (_heart2 != null) _sound.SetGain(_heart2, AudioEngine.VolumeCurve(level / 3)); + if (_breath != null) _sound.SetGain(_breath, AudioEngine.VolumeCurve(level / 4)); + } + + private void MuteVoices() + { + _lastPx = _lastPy = -1; + _sound.SetGain(_caseVoice, 0f); + if (_heart1 != null) _sound.SetGain(_heart1, 0f); + if (_heart2 != null) _sound.SetGain(_heart2, 0f); + if (_breath != null) _sound.SetGain(_breath, 0f); + } + /// Form2's TimestampTimer_Tick. The reader going quiet is not an event — + /// nothing arrives to tell us the tag left, so we have to notice ourselves. + private bool OnTagWatchdog() + { + if (_hasPosition && + (DateTime.Now - _lastPosLine).TotalMilliseconds >= TAG_IDLE_MS) + { + Logger.Write("chip", "tag idle " + TAG_IDLE_MS + "ms — clearing"); + ClearPosition(); + } + return true; // keep the timer alive + } + + private void ClearPosition() + { + _highlighted.Clear(); + _hasPosition = false; + _currentX = _currentY = _currentZ = 0f; + MuteVoices(); + MapArea.QueueDraw(); + } + /// Maps the live Arduino (x,y) into BODY PIXELS using the same /// bounding-box scale/offset DrawCircles uses, so the cursor tracks /// the same coordinate frame as the numbered circles. @@ -480,7 +591,18 @@ MapArea.QueueDraw(); UpdateTunerInfo(); - _sound.Start(SoundWindow.AppFile(def.SoundPath)); + _caseVoice = _sound.Warm(SoundWindow.AppFile(def.SoundPath)); + + if (HeartSoundEnabled) + { + _heart1 = _sound.Warm(SoundWindow.AppFile("sound/SND100.wav")); + _heart2 = _sound.Warm(SoundWindow.AppFile("sound/SND101.wav")); + } + if (BreathSoundEnabled) + _breath = _sound.Warm(SoundWindow.AppFile("sound/SND200.wav")); + + _sound.MuteAll(); // everything loops silently until a tag lands + _lastPx = _lastPy = -1; StartDebugSweep(); Logger.Write("case", "open: " + title + " | map=" + MapPathForView()); @@ -489,7 +611,9 @@ public void StopSession() { StopDebugSweep(); - _sound.Stop(); + // _sound.Stop(); + _sound.MuteAll(); // keep voices warm — re-entry is instant + _lastPx = _lastPy = -1; Logger.Write("case", "closed"); } @@ -1331,6 +1455,7 @@ { _arduino.LineReceived -= OnArduinoLine; _arduino.ConnectionChanged -= OnArduinoConnectionChanged; + if (_watchdogId != 0) { GLib.Source.Remove(_watchdogId); _watchdogId = 0; } _sound.Dispose(); DisposePixbufs(); if (_tuner != null) { _tuner.Destroy(); _tuner = null; } @@ -1423,4 +1548,17 @@ args.RetVal = true; } + /// Form2's checkBoxBlackAudio. Defaults ON; if the glade id is missing, + /// the null check keeps it ON rather than silently killing the audio. + private bool BlackAudioEnabled + { + get { return BlackAudioCheck == null || BlackAudioCheck.Active; } + } + + /// Must match the pixbuf BodyToMapPixel indexes into, or px/py land + /// on the wrong image. + private Gdk.Pixbuf AudioSourcePixbuf + { + get { return (_mapFit == MapFit.FitBody && _mapRaw != null) ? _mapRaw : _map; } + } } \ No newline at end of file diff --git a/program/homepageallv8.glade b/program/homepageallv8.glade index 6cde001..1883e97 100644 --- a/program/homepageallv8.glade +++ b/program/homepageallv8.glade @@ -731,6 +731,7 @@ True True False + True True