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