diff --git a/program/AudioEngine.cs b/program/AudioEngine.cs new file mode 100644 index 0000000..0815f64 --- /dev/null +++ b/program/AudioEngine.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using OpenTK.Audio; +using OpenTK.Audio.OpenAL; + +public class AudioEngine : IDisposable +{ + public class Voice + { + public string Path; + public int Buffer; + public int Source; + public float Gain; + } + + private AudioContext _context; + private readonly Dictionary _voices = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new object(); + + public event Action Log; + + public bool Init() + { + try + { + _context = new AudioContext(); // opens default device + context + _context.MakeCurrent(); + Emit("OpenAL ready: " + AudioContext.CurrentContext.CurrentDevice); + return true; + } + catch (Exception ex) + { + Emit("OpenAL init failed: " + ex.Message); + return false; + } + } + + /// Load + start looping at gain 0. Idempotent. + public Voice Warm(string wavPath) + { + if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath)) + { + Emit("Sound file not found: " + wavPath); + return null; + } + + lock (_lock) + { + Voice v; + if (_voices.TryGetValue(wavPath, out v)) return v; + + try + { + WavFile w = WavFile.Load(wavPath); + + int buf = AL.GenBuffer(); + AL.BufferData(buf, w.Format, w.Data, w.Data.Length, w.SampleRate); + + int src = AL.GenSource(); + AL.Source(src, ALSourcei.Buffer, buf); + AL.Source(src, ALSourceb.Looping, true); + AL.Source(src, ALSourcef.Gain, 0.0f); + AL.SourcePlay(src); // runs continuously, silent + + CheckError("Warm " + Path.GetFileName(wavPath)); + + v = new Voice { Path = wavPath, Buffer = buf, Source = src, Gain = 0f }; + _voices[wavPath] = v; + Emit("Warmed " + Path.GetFileName(wavPath)); + return v; + } + catch (Exception ex) + { + Emit("Load failed " + wavPath + ": " + ex.Message); + return null; + } + } + } + + /// Replaces SourceVoice.SetVolume. Linear 0..1. + public void SetGain(Voice v, float gain) + { + if (v == null) return; + if (gain < 0f) gain = 0f; + if (gain > 1f) gain = 1f; + if (Math.Abs(gain - v.Gain) < 0.001f) return; // skip redundant AL calls + + lock (_lock) + { + v.Gain = gain; + AL.Source(v.Source, ALSourcef.Gain, gain); + + if (gain > 0f && AL.GetSourceState(v.Source) != ALSourceState.Playing) + AL.SourcePlay(v.Source); + } + } + + /// Form2's PlayColorFromStart — restart with no leading gap. + public void PlayFromStart(Voice v, float gain) + { + if (v == null) return; + lock (_lock) + { + AL.SourceRewind(v.Source); + v.Gain = gain; + AL.Source(v.Source, ALSourcef.Gain, gain); + AL.SourcePlay(v.Source); + } + } + + public void MuteAll() + { + lock (_lock) + foreach (var v in _voices.Values) + { + v.Gain = 0f; + AL.Source(v.Source, ALSourcef.Gain, 0f); + } + } + + public void StopAll() + { + lock (_lock) + foreach (var v in _voices.Values) + { + AL.SourceStop(v.Source); + v.Gain = 0f; + } + } + + /// Form2's Volume_Control_XAudio2, unchanged — keeps Linux loudness + /// matched to the Windows build. + public static float VolumeCurve(int range) + { + const double CURVE = 9.5; + double y = Math.Pow(range / 255.0, 1.0 / CURVE); + int db = (int)(10000.0 * y - 10000); + if (db > 0) db = 0; + if (db < -10000) db = -10000; + float lin = (float)Math.Pow(10.0, db / 2000.0); + return Math.Max(0f, Math.Min(1f, lin)); + } + + // ── shims so MapWindow's existing calls still compile ── + + private Voice _main; + + public void Start(string wavPath) + { + _main = Warm(wavPath); + SetGain(_main, 0f); + } + + public void Stop() { MuteAll(); } + + public Voice MainVoice { get { return _main; } } + + public void Dispose() + { + lock (_lock) + { + foreach (var v in _voices.Values) + { + AL.SourceStop(v.Source); + AL.DeleteSource(v.Source); + AL.DeleteBuffer(v.Buffer); + } + _voices.Clear(); + } + if (_context != null) { _context.Dispose(); _context = null; } + } + + private void CheckError(string where) + { + ALError e = AL.GetError(); + if (e != ALError.NoError) Emit(where + ": " + AL.GetErrorString(e)); + } + + private void Emit(string m) + { + var h = Log; + if (h != null) h(m); else Console.WriteLine("[audio] " + m); + } +} \ No newline at end of file diff --git a/program/MapWindow.cs b/program/MapWindow.cs index af71404..f1ef284 100644 --- a/program/MapWindow.cs +++ b/program/MapWindow.cs @@ -109,7 +109,8 @@ [UI] private Label JacketSizeLabel = null; private readonly Language _lang; - private readonly SoundLooper _sound = new SoundLooper(); + // private readonly SoundLooper _sound = new SoundLooper(); + private readonly AudioEngine _sound = new AudioEngine(); // ── state ── private Gdk.Pixbuf _body; // background, native size @@ -153,6 +154,11 @@ private bool ShowCircles { get { return (_tShowCircles == null || _tShowCircles.Active) && !HideCircleCheck.Active; } } private bool ShowGuides { get { return _tGuides != null && _tGuides.Active; } } + + // ── debug state ── + private uint _debugSweepId; + private int _debugLevel; + private int _debugStep = 5; // ══════════════════════════════════════════════════════════════════ @@ -233,6 +239,7 @@ }; _sound.Log += msg => Logger.Write("sound", msg); + _sound.Init(); // NEW — live position feed _arduino.LineReceived += OnArduinoLine; _arduino.ConnectionChanged += OnArduinoConnectionChanged; @@ -449,12 +456,14 @@ MapArea.QueueDraw(); UpdateTunerInfo(); _sound.Start(SoundWindow.AppFile(def.SoundPath)); + StartDebugSweep(); Logger.Write("case", "open: " + title + " | map=" + MapPathForView()); } public void StopSession() { + StopDebugSweep(); _sound.Stop(); Logger.Write("case", "closed"); } @@ -1334,4 +1343,42 @@ if (rb != null) rb.Active = true; else SetView(view); } + + //========================================= + //debug + private void StartDebugSweep() + { + if (!AppConfig.DebugMode) return; + if (_debugSweepId != 0) return; // already running + if (_sound.MainVoice == null) return; // no case loaded yet + + _debugLevel = 0; + _debugStep = 5; + + _debugSweepId = GLib.Timeout.Add(50, () => + { + _debugLevel += _debugStep; + + if (_debugLevel >= 255) { _debugLevel = 255; _debugStep = -5; } + else if (_debugLevel <= 0) { _debugLevel = 0; _debugStep = 5; } + + float gain = AudioEngine.VolumeCurve(_debugLevel); + _sound.SetGain(_sound.MainVoice, gain); + + if (_debugLevel % 50 == 0) + Logger.Write("sound", string.Format("sweep level={0}/255 gain={1:F3}", + _debugLevel, gain)); + return true; // keep firing + }); + + Logger.Write("sound", "debug volume sweep started"); + } + + private void StopDebugSweep() + { + if (_debugSweepId == 0) return; + GLib.Source.Remove(_debugSweepId); + _debugSweepId = 0; + Logger.Write("sound", "debug volume sweep stopped"); + } } \ No newline at end of file diff --git a/program/WavFile.cs b/program/WavFile.cs new file mode 100644 index 0000000..0ac207b --- /dev/null +++ b/program/WavFile.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Text; +using OpenTK.Audio.OpenAL; + +public class WavFile +{ + public byte[] Data; + public ALFormat Format; // Al.FORMAT_* + public int SampleRate; + + public static WavFile Load(string path) + { + using (var fs = File.OpenRead(path)) + using (var br = new BinaryReader(fs)) + { + if (new string(br.ReadChars(4)) != "RIFF") throw new Exception("Not RIFF: " + path); + br.ReadInt32(); + if (new string(br.ReadChars(4)) != "WAVE") throw new Exception("Not WAVE: " + path); + + short channels = 0, bits = 0; + int rate = 0; + byte[] data = null; + + while (fs.Position < fs.Length - 8) + { + string id = new string(br.ReadChars(4)); + int len = br.ReadInt32(); + + if (id == "fmt ") + { + br.ReadInt16(); // audio format (1 = PCM) + channels = br.ReadInt16(); + rate = br.ReadInt32(); + br.ReadInt32(); // byte rate + br.ReadInt16(); // block align + bits = br.ReadInt16(); + if (len > 16) br.ReadBytes(len - 16); + } + else if (id == "data") + { + data = br.ReadBytes(len); + } + else + { + br.ReadBytes(len); // skip unknown chunk + } + + if ((len & 1) == 1) br.ReadByte(); // chunks are word-aligned + } + + if (data == null) throw new Exception("No data chunk: " + path); + + ALFormat fmt; + if (channels == 1 && bits == 8) fmt = ALFormat.Mono8; + else if (channels == 1 && bits == 16) fmt = ALFormat.Mono16; + else if (channels == 2 && bits == 8) fmt = ALFormat.Stereo8; + else if (channels == 2 && bits == 16) fmt = ALFormat.Stereo16; + else throw new Exception("Unsupported WAV: " + channels + "ch " + bits + "bit"); + + return new WavFile { Data = data, Format = fmt, SampleRate = rate }; + } + } +} \ No newline at end of file diff --git a/program/altest.cs b/program/altest.cs new file mode 100644 index 0000000..8b812af --- /dev/null +++ b/program/altest.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using OpenTK.Audio; +using OpenTK.Audio.OpenAL; + +class AlTest +{ + static void Main(string[] args) + { + if (args.Length < 1) + { + Console.WriteLine("usage: altest [gain 0..1]"); + return; + } + float gain = args.Length > 1 ? float.Parse(args[1]) : 1.0f; + + var ctx = new AudioContext(); + ctx.MakeCurrent(); + Console.WriteLine("device: " + AudioContext.CurrentContext.CurrentDevice); + + WavFile w = WavFile.Load(args[0]); + Console.WriteLine("{0} {1} Hz {2} bytes", w.Format, w.SampleRate, w.Data.Length); + + int buf = AL.GenBuffer(); + AL.BufferData(buf, w.Format, w.Data, w.Data.Length, w.SampleRate); + + int src = AL.GenSource(); + AL.Source(src, ALSourcei.Buffer, buf); + AL.Source(src, ALSourcef.Gain, gain); + AL.SourcePlay(src); + + ALError e = AL.GetError(); + if (e != ALError.NoError) Console.WriteLine("AL error: " + AL.GetErrorString(e)); + + while (AL.GetSourceState(src) == ALSourceState.Playing) + Thread.Sleep(50); + + AL.DeleteSource(src); + AL.DeleteBuffer(buf); + ctx.Dispose(); + } +} \ No newline at end of file