diff --git a/program/MapWindow.cs b/program/MapWindow.cs index 87b255c..473e821 100644 --- a/program/MapWindow.cs +++ b/program/MapWindow.cs @@ -182,6 +182,13 @@ private int _debugLevel; private int _debugStep = 5; + // ── waveform boxes ── + private WaveData _heartWave, _breathWave; + private double _heartCursor = -1, _breathCursor = -1; // 0..1, -1 = hidden + private bool _audible; // true while a tag has the sound unmuted + private uint _waveTimerId; + private readonly System.Diagnostics.Stopwatch _playClock = new System.Diagnostics.Stopwatch(); + // ══════════════════════════════════════════════════════════════════ public MapWindow(Builder builder, ArduinoConnection arduino, Language language) @@ -246,8 +253,10 @@ // ── signals ─────────────────────────────────────────────────── MapArea.Drawn += OnMapDrawn; - HeartWaveArea.Drawn += (o, a) => DrawWave(a, HeartWaveArea, 0.86, 0.22, 0.20); - BreathWaveArea.Drawn += (o, a) => DrawWave(a, BreathWaveArea, 0.21, 0.57, 0.78); + // HeartWaveArea.Drawn += (o, a) => DrawWave(a, HeartWaveArea, 0.86, 0.22, 0.20); + // BreathWaveArea.Drawn += (o, a) => DrawWave(a, BreathWaveArea, 0.21, 0.57, 0.78); + HeartWaveArea.Drawn += (o, a) => DrawWave(a, HeartWaveArea, _heartWave, _heartCursor, 0.86, 0.22, 0.20); + BreathWaveArea.Drawn += (o, a) => DrawWave(a, BreathWaveArea, _breathWave, _breathCursor, 0.21, 0.57, 0.78); MapBackButton.Clicked += OnBackClicked; BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); }; @@ -450,6 +459,7 @@ /// matches the Windows build. private void ApplyLevel(int level) { + _audible = level > 0; _sound.SetGain(_caseVoice, AudioEngine.VolumeCurve(level)); if (_heart1 != null) _sound.SetGain(_heart1, AudioEngine.VolumeCurve(level / 3)); @@ -459,6 +469,7 @@ private void MuteVoices() { + _audible = false; _lastPx = _lastPy = -1; _sound.SetGain(_caseVoice, 0f); if (_heart1 != null) _sound.SetGain(_heart1, 0f); @@ -607,6 +618,9 @@ _breath = _sound.Warm(SoundWindow.AppFile("sound/SND200.wav")); _sound.MuteAll(); // everything loops silently until a tag lands + LoadWaveforms(def); + _playClock.Restart(); // voices were just warmed -> loop position 0 + StartWaveTimer(); _lastPx = _lastPy = -1; StartDebugSweep(); @@ -618,6 +632,7 @@ StopDebugSweep(); // _sound.Stop(); _sound.MuteAll(); // keep voices warm — re-entry is instant + StopWaveTimer(); _lastPx = _lastPy = -1; Logger.Write("case", "closed"); } @@ -1478,6 +1493,7 @@ public void Dispose() { + StopWaveTimer(); _arduino.LineReceived -= OnArduinoLine; _arduino.ConnectionChanged -= OnArduinoConnectionChanged; if (_watchdogId != 0) { GLib.Source.Remove(_watchdogId); _watchdogId = 0; } @@ -1556,23 +1572,147 @@ _debugSweepId = 0; Logger.Write("sound", "debug volume sweep stopped"); } - private void DrawWave(DrawnArgs args, DrawingArea area, double r, double g, double b) + + private void DrawWave(DrawnArgs args, DrawingArea area, WaveData wave, double cursor, + double r, double g, double b) { var cr = args.Cr; int w = area.AllocatedWidth, h = area.AllocatedHeight; + double mid = h / 2.0; cr.SetSourceRGB(0.85, 0.85, 0.85); cr.Rectangle(0, 0, w, h); cr.Fill(); cr.SetSourceRGB(r, g, b); - cr.LineWidth = 1.5; - cr.MoveTo(0, h / 2.0); - cr.LineTo(w, h / 2.0); + + if (wave == null || wave.Samples == null || w < 2) + { + // no sound for this box — flat line as before + cr.LineWidth = 1.5; + cr.MoveTo(0, mid); + cr.LineTo(w, mid); + cr.Stroke(); + args.RetVal = true; + return; + } + + float[] mins, maxs; + wave.GetPeaks(w, out mins, out maxs); + + double amp = mid * 0.9; + cr.LineWidth = 1; + for (int x = 0; x < w; x++) + { + double y1 = mid - maxs[x] * amp; + double y2 = mid - mins[x] * amp; + if (y2 - y1 < 1) y2 = y1 + 1; // quiet parts stay visible + cr.MoveTo(x + 0.5, y1); + cr.LineTo(x + 0.5, y2); + } cr.Stroke(); + if (cursor >= 0) + { + double cx = Math.Min(w - 1, cursor * w); + cr.SetSourceRGB(0.1, 0.1, 0.1); // dark cursor, contrasts with red and blue waves + cr.LineWidth = 2; + cr.MoveTo(cx, 0); + cr.LineTo(cx, h); + cr.Stroke(); + } + args.RetVal = true; } + + private void LoadWaveforms(CaseDefinition def) + { + _heartWave = _breathWave = null; + _heartCursor = _breathCursor = -1; + + WaveData caseWave = LoadWave(def.SoundPath); + if (def.IsHeart) + { + _heartWave = caseWave; + if (BreathSoundEnabled) _breathWave = LoadWave("sound/SND200.wav"); + } + else + { + _breathWave = caseWave; + if (HeartSoundEnabled) _heartWave = LoadWave("sound/SND100.wav"); + } + + HeartWaveArea.QueueDraw(); + BreathWaveArea.QueueDraw(); + } + + private static WaveData LoadWave(string rel) + { + string err; + WaveData w = WaveData.Load(SoundWindow.AppFile(rel), out err); + if (w == null) Logger.Write("wave", "load failed " + rel + ": " + err); + else Logger.Write("wave", "loaded " + rel + " (" + + w.Duration.ToString("F2", CultureInfo.InvariantCulture) + " s)"); + return w; + } + + private void StartWaveTimer() + { + if (_waveTimerId != 0) return; + _waveTimerId = GLib.Timeout.Add(16, OnWaveTick); // ~60 fps + } + + private void StopWaveTimer() + { + if (_waveTimerId != 0) { GLib.Source.Remove(_waveTimerId); _waveTimerId = 0; } + _audible = false; + _heartCursor = _breathCursor = -1; + if (HeartWaveArea != null) HeartWaveArea.QueueDraw(); + if (BreathWaveArea != null) BreathWaveArea.QueueDraw(); + } + + // private bool OnWaveTick() + // { + // if (!_audible) return true; // muted: cursor stays frozen + + // double t = _playClock.Elapsed.TotalSeconds; + // _heartCursor = CursorFor(_heartWave, t); + // _breathCursor = CursorFor(_breathWave, t); + + // HeartWaveArea.QueueDraw(); + // BreathWaveArea.QueueDraw(); + // return true; + // } + private bool OnWaveTick() + { + if (!_audible) + { + // muted: hide the cursor (redraw only once, when it disappears) + if (_heartCursor >= 0 || _breathCursor >= 0) + { + _heartCursor = _breathCursor = -1; + HeartWaveArea.QueueDraw(); + BreathWaveArea.QueueDraw(); + } + return true; + } + + double t = _playClock.Elapsed.TotalSeconds; + _heartCursor = CursorFor(_heartWave, t); + _breathCursor = CursorFor(_breathWave, t); + + HeartWaveArea.QueueDraw(); + BreathWaveArea.QueueDraw(); + return true; + } + + /// Where the looping voice is right now, 0..1 across the box. + private static double CursorFor(WaveData w, double seconds) + { + if (w == null || w.Duration <= 0) return -1; + return (seconds % w.Duration) / w.Duration; + } + /// 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 diff --git a/program/WaveData.cs b/program/WaveData.cs new file mode 100644 index 0000000..8d84899 --- /dev/null +++ b/program/WaveData.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Text; + +/// A WAV file decoded to mono floats, plus a per-pixel min/max cache for drawing. +public class WaveData +{ + public float[] Samples; + public int SampleRate; + + private int _cacheWidth = -1; + private float[] _mins, _maxs; + + public double Duration + { + get { return (Samples == null || SampleRate <= 0) ? 0 : (double)Samples.Length / SampleRate; } + } + + public static WaveData Load(string path, out string error) + { + float[] s; int rate; + if (!ReadWav(path, out s, out rate, out error)) return null; + return new WaveData { Samples = s, SampleRate = rate }; + } + + /// Min/max of the samples that fall into each of `width` pixel columns. + public void GetPeaks(int width, out float[] mins, out float[] maxs) + { + if (width != _cacheWidth) + { + _cacheWidth = width; + _mins = new float[width]; + _maxs = new float[width]; + int n = Samples.Length; + + for (int x = 0; x < width; x++) + { + int s0 = (int)((long)x * n / width); + int s1 = (int)((long)(x + 1) * n / width); + if (s1 <= s0) s1 = s0 + 1; + if (s1 > n) s1 = n; + + float mn = 1f, mx = -1f; + for (int i = s0; i < s1; i++) + { + float v = Samples[i]; + if (v < mn) mn = v; + if (v > mx) mx = v; + } + _mins[x] = mn; + _maxs[x] = mx; + } + } + mins = _mins; + maxs = _maxs; + } + + // PCM 8/16/24/32-bit or float32, mono or stereo (left channel), normalized to [-1, 1] + public static bool ReadWav(string path, out float[] samples, out int sampleRate, out string error) + { + samples = null; sampleRate = 0; error = null; + try + { + if (!File.Exists(path)) { error = "not found: " + path; return false; } + + using (var br = new BinaryReader(File.OpenRead(path))) + { + long len = br.BaseStream.Length; + if (Id(br) != "RIFF") { error = "not RIFF"; return false; } + br.ReadInt32(); + if (Id(br) != "WAVE") { error = "not WAVE"; return false; } + + int fmtTag = 0, channels = 0, bits = 0; + byte[] data = null; + + while (br.BaseStream.Position + 8 <= len) + { + string id = Id(br); + long size = (uint)br.ReadInt32(); + long start = br.BaseStream.Position; + + if (id == "fmt ") + { + fmtTag = (ushort)br.ReadInt16(); + channels = br.ReadInt16(); + sampleRate = br.ReadInt32(); + br.ReadInt32(); // byte rate + br.ReadInt16(); // block align + bits = br.ReadInt16(); + if (fmtTag == 0xFFFE && size >= 40) // WAVE_FORMAT_EXTENSIBLE + { + br.ReadInt16(); br.ReadInt16(); br.ReadInt32(); + fmtTag = (ushort)br.ReadInt16(); + } + } + else if (id == "data") + { + data = br.ReadBytes((int)Math.Min(size, len - start)); + break; + } + + long next = start + size + (size & 1); + if (next > len) break; + br.BaseStream.Position = next; + } + + if (data == null || channels <= 0 || sampleRate <= 0 || bits <= 0) + { error = "missing fmt/data chunk"; return false; } + + int frame = (bits / 8) * channels; + int frames = data.Length / frame; + if (frames == 0) { error = "empty data"; return false; } + + var s = new float[frames]; + float peak = 0f; + for (int i = 0; i < frames; i++) + { + int o = i * frame; + float v; + switch (bits) + { + case 8: v = (data[o] - 128) / 128f; break; + case 16: v = BitConverter.ToInt16(data, o) / 32768f; break; + case 24: v = (data[o] | (data[o + 1] << 8) | ((sbyte)data[o + 2] << 16)) / 8388608f; break; + case 32: v = fmtTag == 3 ? BitConverter.ToSingle(data, o) + : BitConverter.ToInt32(data, o) / 2147483648f; break; + default: error = "unsupported bits: " + bits; return false; + } + s[i] = v; + if (Math.Abs(v) > peak) peak = Math.Abs(v); + } + + if (peak > 0f) + for (int i = 0; i < frames; i++) s[i] /= peak; + + samples = s; + return true; + } + } + catch (Exception ex) { error = ex.Message; return false; } + } + + private static string Id(BinaryReader br) + { + return Encoding.ASCII.GetString(br.ReadBytes(4)); + } +} \ No newline at end of file