#nullable enable using ManagedBass; using ManagedBass.Mix; using T3.Core.Audio; // ReSharper disable UnusedMember.Local // ReSharper disable MemberCanBePrivate.Global namespace Lib.io.audio { /// /// Generates test tones procedurally in real-time via the operator mixer. /// No external files are created - audio is synthesized on-the-fly. /// /// Supports two input modes: /// - Trigger: A pulse (0→1) starts playback for the specified duration /// - Gate: Sound plays while input is true, releases when false (duration ignored) /// /// Uses sample-accurate AdsrCalculator for envelope shaping in the audio thread. /// [Guid("7c8f3a2e-9d4b-4e1f-8a5c-6b2d9f7e4c3a")] internal sealed class AudioToneGenerator : Instance { [Input(Guid = "3e9a7f2c-4d8b-4c1f-9e5a-2b7d6f8c4a5e")] public readonly InputSlot Trigger = new(); [Input(Guid = "8f4a2e9c-7d3b-4e1f-8c5a-9b2d6f7c3a4e")] public readonly InputSlot Frequency = new(); [Input(Guid = "2c9f4e7a-3d8b-4a1f-9e5c-6b2d7f8c4a3e")] public readonly InputSlot Duration = new(); [Input(Guid = "c0645e37-db4e-4658-9d65-96478851f6f6")] public readonly InputSlot Volume = new(); [Input(Guid = "1a3f4b7c-12d3-4a5b-9c7d-8e1f2a3b4c5d")] public readonly InputSlot Mute = new(); [Input(Guid = "5a7e9f2c-8d4b-4c1f-9a5e-3b2d6f7c8a4e", MappedType = typeof(WaveformTypes))] public readonly InputSlot WaveformType = new(); [Input(Guid = "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a", MappedType = typeof(AdsrCalculator.TriggerMode))] public readonly InputSlot TriggerMode = new(); // ADSR Envelope as Vector4: X=Attack, Y=Decay, Z=Sustain, W=Release [Input(Guid = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")] public readonly InputSlot Envelope = new(); [Output(Guid = "b7e2c1a4-5d3f-4e8a-9c2f-1e4b7a6c3d8f")] public readonly Slot Result = new(); [Output(Guid = "960aa0a3-89b4-4eff-8b52-36ff6965cf8f")] public readonly Slot IsPlaying = new(); [Output(Guid = "b09d215a-bcf0-479a-a649-56f9c698ecb1")] public readonly Slot GetLevel = new(); private Guid _operatorId; private ProceduralToneStream? _toneStream; private bool _previousTrigger; public AudioToneGenerator() { Result.UpdateAction += UpdatePlayback; IsPlaying.UpdateAction += UpdateStatus; GetLevel.UpdateAction += UpdateStatus; } private void EnsureOperatorId() { if (_operatorId != Guid.Empty) return; _operatorId = AudioPlayerUtils.ComputeInstanceGuid(InstancePath); Log.Gated.Audio($"[TestToneGenerator] Initialized: {_operatorId}"); } private void UpdateStatus(EvaluationContext context) { EnsureOperatorId(); if (_toneStream == null) { IsPlaying.Value = false; GetLevel.Value = 0; return; } IsPlaying.Value = _toneStream.Adsr.IsActive; GetLevel.Value = _toneStream.GetLevel(); } private void UpdatePlayback(EvaluationContext context) { EnsureOperatorId(); var shouldPlay = Trigger.GetValue(context); var frequency = Frequency.GetValue(context); var duration = Duration.GetValue(context); var volume = Volume.GetValue(context); var mute = Mute.GetValue(context); var waveformType = WaveformType.GetValue(context); var triggerMode = (AdsrCalculator.TriggerMode)TriggerMode.GetValue(context); var envelope = Envelope.GetValue(context); // Extract ADSR from Vector4: X=Attack, Y=Decay, Z=Sustain, W=Release var attack = envelope.X > 0 ? envelope.X : 0.01f; var decay = envelope.Y > 0 ? envelope.Y : 0.1f; var sustain = envelope.Z >= 0 ? Math.Clamp(envelope.Z, 0f, 1f) : 0.7f; var release = envelope.W > 0 ? envelope.W : 0.3f; // Apply defaults if (frequency <= 0) frequency = 440f; if (duration <= 0) duration = float.MaxValue; // Ensure stream is created EnsureToneStream(); if (_toneStream == null) { IsPlaying.Value = false; GetLevel.Value = 0; return; } // Update audio parameters (thread-safe) _toneStream.Frequency = frequency; _toneStream.WaveformType = (WaveformTypes)waveformType; _toneStream.SetVolume(volume, mute); // Update ADSR parameters on the shared calculator (thread-safe) _toneStream.Adsr.SetParameters(attack, decay, sustain, release); _toneStream.Adsr.SetMode(triggerMode); _toneStream.Adsr.SetDuration(duration); // Detect edges and send trigger/release signals to audio thread var risingEdge = shouldPlay && !_previousTrigger; var fallingEdge = !shouldPlay && _previousTrigger; _previousTrigger = shouldPlay; if (risingEdge) { _toneStream.Adsr.TriggerAttack(); Log.Gated.Audio($"[TestToneGenerator] ▶ Triggered @ {frequency}Hz"); } else if (fallingEdge) { if (triggerMode == AdsrCalculator.TriggerMode.Gate) { _toneStream.Adsr.TriggerRelease(); Log.Gated.Audio($"[TestToneGenerator] ~ Release triggered"); } else { // In Trigger mode, falling edge has no effect - ADSR runs its full cycle } } else if (!shouldPlay && !_toneStream.Adsr.IsActive) { // Trigger is off and ADSR has fully completed - ensure clean idle state _toneStream.Adsr.Reset(); } bool isActive = _toneStream.Adsr.IsActive; IsPlaying.Value = isActive; GetLevel.Value = _toneStream.GetLevel(); } private void EnsureToneStream() { if (_toneStream != null) return; if (!AudioMixerManager.IsInitialized) { AudioMixerManager.Initialize(); if (AudioMixerManager.OperatorMixerHandle == 0) { // Only log once, not every frame if (!_mixerWarningLogged) { Log.Warning("[TestToneGenerator] Mixer not available"); _mixerWarningLogged = true; } return; } } _toneStream = ProceduralToneStream.Create(AudioMixerManager.OperatorMixerHandle); if (_toneStream != null) Log.Gated.Audio($"[TestToneGenerator] Created procedural tone stream"); } private bool _mixerWarningLogged; ~AudioToneGenerator() { _toneStream?.Dispose(); } private enum WaveformTypes { Sine = 0, Square = 1, Sawtooth = 2, Triangle = 3, WhiteNoise = 4, PinkNoise = 5 } /// /// Procedural tone stream that generates waveforms in real-time via BASS callback stream. /// Uses the shared AdsrCalculator for sample-accurate envelope calculation. /// private sealed class ProceduralToneStream { private const int Channels = 1; // Thread-safe audio parameters private volatile float _frequency = 440f; private volatile int _waveformType; private volatile float _currentVolume = 1.0f; private volatile bool _isMuted; // Shared ADSR calculator (used by audio thread) public readonly AdsrCalculator Adsr = new(); // Sample rate from AudioConfig (set at creation) private readonly int _sampleRate; public float Frequency { get => _frequency; set => _frequency = Math.Max(20f, Math.Min(20000f, value)); } public WaveformTypes WaveformType { get => (WaveformTypes)_waveformType; set => _waveformType = (int)value; } private int _streamHandle; // ReSharper disable once NotAccessedField.Local private readonly int _mixerHandle; private double _phase; private readonly List _waveformBuffer = new(); private readonly List _spectrumBuffer = new(); private float _lastLevel; private readonly StreamProcedure _streamProc; private GCHandle _gcHandle; private ProceduralToneStream(int mixerHandle) { _mixerHandle = mixerHandle; _streamProc = StreamCallback; _sampleRate = AudioConfig.MixerFrequency; Adsr.SetSampleRate(_sampleRate); } public static ProceduralToneStream? Create(int mixerHandle) { var instance = new ProceduralToneStream(mixerHandle); instance._gcHandle = GCHandle.Alloc(instance); var streamHandle = Bass.CreateStream( AudioConfig.MixerFrequency, Channels, BassFlags.Float | BassFlags.Decode, instance._streamProc, GCHandle.ToIntPtr(instance._gcHandle)); if (streamHandle == 0) { Log.Error($"[ProceduralToneStream] Failed to create stream: {Bass.LastError}"); instance._gcHandle.Free(); return null; } instance._streamHandle = streamHandle; if (!BassMix.MixerAddChannel(mixerHandle, streamHandle, BassFlags.MixerChanBuffer)) { Log.Error($"[ProceduralToneStream] Failed to add to mixer: {Bass.LastError}"); Bass.StreamFree(streamHandle); instance._gcHandle.Free(); return null; } Bass.ChannelSetAttribute(streamHandle, ChannelAttribute.Volume, 1f); return instance; } private int StreamCallback(int handle, IntPtr buffer, int length, IntPtr user) { int floatCount = length / sizeof(float); int sampleCount = floatCount; // Mono: 1 sample per float var floatBuffer = new float[floatCount]; // Read thread-safe parameters float freq = _frequency; int waveType = _waveformType; float baseVol = _isMuted ? 0f : _currentVolume; double phaseIncrement = 2.0 * Math.PI * freq / _sampleRate; // Generate mono samples with sample-accurate envelope for (int i = 0; i < sampleCount; i++) { // Get envelope value for this sample (advances internal state) float envelopeGain = Adsr.UpdateSample(); float sample = GenerateSample(_phase, waveType) * baseVol * envelopeGain; _phase += phaseIncrement; if (_phase >= 2.0 * Math.PI) _phase -= 2.0 * Math.PI; floatBuffer[i] = sample; } Marshal.Copy(floatBuffer, 0, buffer, floatCount); return length; } // Instance-level noise state to avoid static issues with multiple instances private readonly Random _noiseRng = new(); private double _pinkB0, _pinkB1, _pinkB2; private float GenerateSample(double phase, int waveType) { double normalizedPhase = phase - Math.Floor(phase / (2.0 * Math.PI)) * 2.0 * Math.PI; double t = normalizedPhase / (2.0 * Math.PI); switch (waveType) { case 0: // Sine return (float)Math.Sin(normalizedPhase); case 1: // Square return t < 0.5 ? 0.8f : -0.8f; case 2: // Sawtooth return (float)(2.0 * t - 1.0) * 0.8f; case 3: // Triangle return (float)(4.0 * Math.Abs(t - 0.5) - 1.0) * 0.8f; case 4: // WhiteNoise return (float)(_noiseRng.NextDouble() * 2.0 - 1.0) * 0.5f; case 5: // PinkNoise { double white = _noiseRng.NextDouble() * 2.0 - 1.0; _pinkB0 = 0.99765 * _pinkB0 + white * 0.0990460; _pinkB1 = 0.96300 * _pinkB1 + white * 0.2965164; _pinkB2 = 0.57000 * _pinkB2 + white * 1.0526913; double pink = _pinkB0 + _pinkB1 + _pinkB2 + white * 0.1848; return (float)(pink * 0.15); } default: return (float)Math.Sin(normalizedPhase); } } public void SetVolume(float volume, bool mute) { _currentVolume = Math.Clamp(volume, 0f, 1f); _isMuted = mute; } public float GetLevel() { if (!Adsr.IsActive) return 0f; var level = BassMix.ChannelGetLevel(_streamHandle); if (level == -1) return _lastLevel; var left = (level & 0xFFFF) / 32768f; var right = ((level >> 16) & 0xFFFF) / 32768f; _lastLevel = Math.Min(Math.Max(left, right), 1f); return _lastLevel; } public void Dispose() { Adsr.Reset(); BassMix.MixerRemoveChannel(_streamHandle); Bass.StreamFree(_streamHandle); if (_gcHandle.IsAllocated) _gcHandle.Free(); } } } }