#include #include "mungal4.h" #pragma hdrstop #include "l4audrnd.h" #include "..\munga\notation.h" #include "openal/alc.h" // //############################################################################# // L4AudioRenderer //############################################################################# // L4AudioRenderer::L4AudioRenderer( RendererRate render_rate, Logical mission_review_mode ): AudioRenderer(render_rate), runningAudioSourceSocket(NULL, False), dormantAudioSourceSocket(NULL), mixingAudioSourceSocket(NULL, False) { missionReviewMode = mission_review_mode; nextCalculateMixFrame = NullAudioFrameCount; } // //############################################################################# // ~L4AudioRenderer //############################################################################# // L4AudioRenderer::~L4AudioRenderer() { //Close OpenAL ALCcontext *context = alcGetCurrentContext(); ALCdevice *device = alcGetContextsDevice(context); alcMakeContextCurrent(NULL); alcDestroyContext(context); alcCloseDevice(device); // //---------------------------------------------------------------------- // Verify that all of entities have become uninteresting //---------------------------------------------------------------------- // #if DEBUG_LEVEL>0 { RunningSourceIterator iterator(&runningAudioSourceSocket); Verify(iterator.GetSize() == 0); } { DormantSourceIterator iterator(&dormantAudioSourceSocket); Verify(iterator.GetSize() == 0); } #endif // //---------------------------------------------------------------------- // Unload resources //---------------------------------------------------------------------- // Check(&audioResourceManager); // audioResourceManager.UnloadResources(&audioHardware); // //---------------------------------------------------------------------- // Close audio hardware //---------------------------------------------------------------------- // // Check(&audioHardware); // audioHardware.Close(); } // //############################################################################# // TestInstance //############################################################################# // Logical L4AudioRenderer::TestInstance() const { AudioRenderer::TestInstance(); Check(&audioHardware); Check(&runningAudioSourceSocket); Check(&dormantAudioSourceSocket); Check(&mixingAudioSourceSocket); return True; } // //############################################################################# // Initialize //############################################################################# // void L4AudioRenderer::Initialize() { Check(this); if (getenv("BT_AUDIO_LOG")) DEBUG_STREAM << "[audio] L4AudioRenderer::Initialize ENTERED\n" << std::flush; // //---------------------------------------------------------------------- // Call inherited method //---------------------------------------------------------------------- // AudioRenderer::Initialize(); // //---------------------------------------------------------------------- // Get the audio head //---------------------------------------------------------------------- // AudioHead *audio_head = GetAudioHead(); Check(audio_head); // //---------------------------------------------------------------------- // Open the audio renderer notation file //---------------------------------------------------------------------- // int ret; CString audio_file_name; if (missionReviewMode) { audio_file_name = "audio\\audiomr.ini"; } else { audio_file_name = "audio\\audio.ini"; } NotationFile notation_file(audio_file_name); Check(¬ation_file); if (getenv("BT_AUDIO_LOG")) DEBUG_STREAM << "[audio] notation file opened; about to read scalars\n" << std::flush; // //---------------------------------------------------------------------- // Define clipping sphere (m) //---------------------------------------------------------------------- // Scalar clipping_radius; ret = notation_file.GetEntry( "AudioRenderer", "clipping_radius", &clipping_radius ); if (!ret) { Fail("L4AudioRenderer::Initialize - clipping_radius not defined"); } Check(audio_head); audio_head->DefineClippingSphere(clipping_radius); // //---------------------------------------------------------------------- // Distance to speakers or width of head entity (m) //---------------------------------------------------------------------- // Scalar distance_between_ears; ret = notation_file.GetEntry( "AudioRenderer", "distance_between_ears", &distance_between_ears ); if (!ret) { Fail("L4AudioRenderer::Initialize - distance_between_ears not defined"); } Check(audio_head); audio_head->SetDistanceBetweenEars(distance_between_ears); // //---------------------------------------------------------------------- // Define amplitude rolloff characteristics //---------------------------------------------------------------------- // Scalar amplitude_rolloff; Scalar amplitude_rolloff_knee; Scalar amplitude_rolloff_distance_scale; ret = notation_file.GetEntry( "AudioRenderer", "amplitude_rolloff", &litude_rolloff ); if (!ret) { Fail("L4AudioRenderer::Initialize - amplitude_rolloff not defined"); } ret = notation_file.GetEntry( "AudioRenderer", "amplitude_rolloff_distance_scale", &litude_rolloff_distance_scale ); if (!ret) { Fail("L4AudioRenderer::Initialize - amplitude_rolloff_distance_scale not defined"); } ret = notation_file.GetEntry( "AudioRenderer", "amplitude_rolloff_knee", &litude_rolloff_knee ); if (!ret) { Fail("L4AudioRenderer::Initialize - amplitude_rolloff_knee not defined"); } Check(audio_head); audio_head->ControlAmplitudeRollOff( amplitude_rolloff, amplitude_rolloff_knee, amplitude_rolloff_distance_scale ); // //---------------------------------------------------------------------- // Define high frequency rolloff characteristics //---------------------------------------------------------------------- // Scalar high_frequency_rolloff; Scalar high_frequency_rolloff_knee; Scalar high_frequency_rolloff_distance_scale; ret = notation_file.GetEntry( "AudioRenderer", "high_frequency_rolloff", &high_frequency_rolloff ); if (!ret) { Fail("L4AudioRenderer::Initialize - high_frequency_rolloff not defined"); } ret = notation_file.GetEntry( "AudioRenderer", "high_frequency_rolloff_knee", &high_frequency_rolloff_knee ); if (!ret) { Fail("L4AudioRenderer::Initialize - high_frequency_rolloff_knee not defined"); } ret = notation_file.GetEntry( "AudioRenderer", "high_frequency_rolloff_distance_scale", &high_frequency_rolloff_distance_scale ); if (!ret) { Fail("L4AudioRenderer::Initialize - high_frequency_rolloff_distance_scale not defined"); } Check(audio_head); audio_head->ControlHighFrequencyRollOff( high_frequency_rolloff, high_frequency_rolloff_knee, high_frequency_rolloff_distance_scale ); // //---------------------------------------------------------------------- // Define Doppler characterisitics //---------------------------------------------------------------------- // Scalar doppler_range; Scalar speed_of_sound; ret = notation_file.GetEntry( "AudioRenderer", "doppler_range", &doppler_range ); if (!ret) { Fail("L4AudioRenderer::Initialize - doppler_range not defined"); } ret = notation_file.GetEntry( "AudioRenderer", "speed_of_sound", &speed_of_sound ); if (!ret) { Fail("L4AudioRenderer::Initialize - speed_of_sound not defined"); } Check(audio_head); audio_head->ControlDopplerEffect( doppler_range, speed_of_sound ); // //---------------------------------------------------------------------- // Define source compression characterisitics //---------------------------------------------------------------------- // Scalar compression_exponent; Scalar compression_scale; ret = notation_file.GetEntry( "AudioRenderer", "compression_exponent", &compression_exponent ); if (!ret) { Fail("L4AudioRenderer::Initialize - compression_exponent not defined"); } ret = notation_file.GetEntry( "AudioRenderer", "compression_scale", &compression_scale ); if (!ret) { Fail("L4AudioRenderer::Initialize - compression_scale not defined"); } Check(audio_head); audio_head->ControlSourceCompression( compression_exponent, compression_scale ); // //---------------------------------------------------------------------- // Set ITD difference //---------------------------------------------------------------------- // Scalar itd_difference; ret = notation_file.GetEntry( "AudioRenderer", "itd_difference", &itd_difference ); if (!ret) { Fail("L4AudioRenderer::Initialize - itd_difference not defined"); } Check(audio_head); audio_head->SetITDDifference(itd_difference); // //---------------------------------------------------------------------- // Set global reverb scale //---------------------------------------------------------------------- // Scalar global_reverb_scale; ret = notation_file.GetEntry( "AudioRenderer", "global_reverb_scale", &global_reverb_scale ); if (!ret) { Fail("L4AudioRenderer::Initialize - global_reverb_scale not defined"); } Check(audio_head); audio_head->SetGlobalReverbScale(global_reverb_scale); // //---------------------------------------------------------------------- // Initialize the audio hardware //---------------------------------------------------------------------- // // Check(&audioHardware); // audioHardware.Initialize(); //Start OpenAL ALCdevice *device = alcOpenDevice(NULL); if (device) { // AUDIO DROPOUTS IN HEAVY COMBAT (field: "audio cutting in and out toward // the end of the match"; night-5 logs show 2017 ACQUIRE FAILED lines in a // single match, every one of them at live=256). // // We were passing NULL for the attribute list, so OpenAL Soft applied its // DEFAULT budget of 256 mono sources -- an OpenAL default, nothing to do // with the 1995 audio hardware. The pool is not leaking: the night-5 // census sits at a healthy 45-60 live, spikes to the 256 ceiling during // firefights, and drains straight back afterwards, so the engine's // priority steal loop (AudioSourceStop/SuspendMaintenance -> ReleaseChannels // -> ReleaseSourceSet) is doing its job. It simply cannot keep up with the // burst: a busy match logs ~7200 explosions, each spawning three // DPLIndependantEffect voices, and while the pool is pinned every new // acquire fails outright and that sound is silently dropped. // // ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win. // // ⚠⚠ UPDATE 2026-08-01 (#32): the "no field complaints since" claim below // did NOT hold. Every 4.11.674 player log is saturated -- 3031/4657/5245/ // 6275/6571 failures across five machines, first failure ~10% into a match // and still failing at 97%. The root cause was NOT the source budget at // all: RequestAudioChannels was alGenSources'ing per sound event and // ReleaseSourceSet alDeleteSources'ing on release, so combat CHURNED // through the ceiling. Sources are now POOLED and recycled (see // BTAudioPoolAcquire below), which fixes it WITHOUT touching this budget. // Measured on the same bench: frame time went 8.67ms -> 7.79ms over ~10k // frames, i.e. removing the churn is a net CPU WIN, so the governor // argument below does not apply to pooling. The reasoning about raising // the CAP (more voices mixing = more CPU) still stands on its own, which // is why BT_AUDIO_SOURCES remains opt-in and unset by default. // // (Historical:) the 2026-07-23 fix a999e5c addressed the atomic-delete // leak. What remained in the night-5 logs is transient exhaustion at the // peak of a firefight, where a dropped voice competes with ~256 already // sounding, so it is very likely sub-perceptual. Against that: the 256 // ceiling also acts as a // GOVERNOR. The steal loop only steals when the incoming source outranks // a running one, so a higher cap means many more voices mixing at once -- // with EFX reverb + the lowpass chains live, that is real CPU, spent // exactly during heavy combat when the frame budget is tightest. // // So this stays opt-in: set BT_AUDIO_SOURCES= to raise it (no rebuild // needed) if dropouts are ever reported again, and measure frame time // while you do. Unset = the shipped OpenAL Soft default, unchanged. ALCint monoWanted = 0; if (const char *sv = getenv("BT_AUDIO_SOURCES")) { int n = atoi(sv); if (n >= 64 && n <= 4096) monoWanted = n; } ALCcontext *context; if (monoWanted > 0) { ALCint attrs[5]; attrs[0] = ALC_MONO_SOURCES; attrs[1] = monoWanted; attrs[2] = ALC_STEREO_SOURCES; attrs[3] = 16; attrs[4] = 0; context = alcCreateContext(device, attrs); } else { context = alcCreateContext(device, NULL); // default budget (256) } alcMakeContextCurrent(context); // Report what the driver actually GRANTED -- a request is not a promise. // (Asking for 64 grants 240: OpenAL Soft has a floor of its own, which is // also why the NULL default lands on 256.) if (getenv("BT_AUDIO_LOG") || monoWanted > 0) { ALCint monoGot = 0, stereoGot = 0; alcGetIntegerv(device, ALC_MONO_SOURCES, 1, &monoGot); alcGetIntegerv(device, ALC_STEREO_SOURCES, 1, &stereoGot); DEBUG_STREAM << "[audio] source budget: requested mono=" << (monoWanted > 0 ? monoWanted : 0) << " (0 = driver default)" << " granted mono=" << monoGot << " stereo=" << stereoGot << std::endl << std::flush; } // Master volume: AL_GAIN on the listener scales EVERY source. Default 0.6 // (the raw samples are hot). Priority: BT_AUDIO_VOLUME env > // content\volume.cfg (written by the runtime -/+ keys, issue #26) > 0.6. float masterVol = 0.6f; { FILE *cfg = fopen("volume.cfg", "rt"); if (cfg != NULL) { float f = -1.0f; if (fscanf(cfg, "%f", &f) == 1 && f >= 0.0f && f <= 1.5f) masterVol = f; fclose(cfg); } } if (const char *v = getenv("BT_AUDIO_VOLUME")) { float f = (float)atof(v); if (f >= 0.0f) masterVol = f; } extern float gBTMasterVolume; gBTMasterVolume = masterVol; alListenerf(AL_GAIN, masterVol); if (getenv("BT_AUDIO_LOG")) DEBUG_STREAM << "[audio] OpenAL device OPENED + context current; master gain=" << masterVol << "\n" << std::flush; // (task #50, AUDIO_FIDELITY F9/F11) EFX bridge: the authored lowpass // chains + the wet-exterior/dry-cockpit reverb split, at the authentic // AUDIO.INI global_reverb_scale read above. { extern bool EFX_Initialize(float global_reverb_scale); EFX_Initialize(global_reverb_scale); } } else { if (getenv("BT_AUDIO_LOG")) DEBUG_STREAM << "[audio] OpenAL device FAILED to open (alcOpenDevice NULL)\n" << std::flush; } // //---------------------------------------------------------------------- // Preload resources //---------------------------------------------------------------------- // Check(&audioResourceManager); audioResourceManager.PreloadResources(¬ation_file); } // //############################################################################# // NotifyOfNewInterestingEntity //############################################################################# // void L4AudioRenderer::NotifyOfNewInterestingEntity(Entity *interesting_entity) { SET_AUDIO_RENDERER(); SET_AUDIO_RENDERER_CREATE_OBJECTS(); Check(this); Check(interesting_entity); Entity *linked_entity = GetLinkedEntity(); Check(linked_entity); AudioRepresentation audio_representation = (AudioRepresentation)interesting_entity->GetAudioRepresentation( linked_entity ); Check(&audioResourceManager); audioResourceManager.CreateEntityAudioObjects( interesting_entity, audio_representation ); CLEAR_AUDIO_RENDERER_CREATE_OBJECTS(); CLEAR_AUDIO_RENDERER(); } // //############################################################################# // NotifyOfBecomingUninterestingEntity //############################################################################# // void L4AudioRenderer::NotifyOfBecomingUninterestingEntity( Entity *uninteresting_entity ) { SET_AUDIO_RENDERER(); SET_AUDIO_RENDERER_DESTROY_OBJECTS(); Check(this); Check(uninteresting_entity); Check(&audioResourceManager); audioResourceManager.DestroyEntityAudioObjects(uninteresting_entity); CLEAR_AUDIO_RENDERER_DESTROY_OBJECTS(); CLEAR_AUDIO_RENDERER(); } // //############################################################################# // LoadMissionImplementation //############################################################################# // void L4AudioRenderer::LoadMissionImplementation(Mission*) { Check(this); } // //############################################################################# // ShutdownImplementation //############################################################################# // void L4AudioRenderer::ShutdownImplementation() { Check(this); } // //############################################################################# // SuspendImplementation //############################################################################# // void L4AudioRenderer::SuspendImplementation() { Check(this); } // //############################################################################# // ResumeImplementation //############################################################################# // void L4AudioRenderer::ResumeImplementation() { Check(this); } // //############################################################################# // StartRequest //############################################################################# // void L4AudioRenderer::StartRequest(L4AudioSource *audio_source) { Check(this); Check(audio_source); if (getenv("BT_AUDIO_SPATIAL")) { static int s_sr=0; if (s_sr++<5000) DEBUG_STREAM << "[startreq] src=" << (void*)audio_source << " state=" << (int)audio_source->GetAudioSourceState() << " class=" << (int)audio_source->GetClassID() << "\n" << std::flush; } // //-------------------------------------------------------------------------- // Verify that the source is stopped //-------------------------------------------------------------------------- // Verify(audio_source->GetAudioSourceState() == StoppedAudioSourceState); // //-------------------------------------------------------------------------- // Request audio resources //-------------------------------------------------------------------------- // Logical resources_available; /* AudioChannelSet channel_set_result;*/ SourceSet *source_result = audio_source->GetAudioChannelSet(); resources_available = RequestAudioResources( audio_source, source_result ); if (!resources_available) { if (getenv("BT_AUDIO_SPATIAL")) { static int s_rf=0; if (s_rf++<40) DEBUG_STREAM << "[startreq] RESOURCE FAIL src=" << (void*)audio_source << "\n" << std::flush; } // Audio-dropout fix: a failed acquisition can leave a PARTIAL set // (alGenSources succeeded for some slots before the pool ran dry). // A dropped transient never plays and nothing else ever released it, // so the partial sources dangled forever -- once the pool exhausted // ONCE, every drop leaked a little more and it never recovered. audio_source->ReleaseChannels(); if (audio_source->GetAudioRenderType() == SustainedAudioRenderType) { // // Set state to dormant and add the source to the dormant list // audio_source->AssignAudioSourceState(DormantAudioSourceState); dormantAudioSourceSocket.Add(audio_source); } return; } // //-------------------------------------------------------------------------- // Start the audio source //-------------------------------------------------------------------------- // // // Update source spatial model // Set channel set of source // Source start implementation // Set state of source to running // audio_source->UpdateSpatialModel(GetAudioHead()); audio_source->SetAudioChannelSet(*source_result); audio_source->StartImplementation(); audio_source->AssignAudioSourceState(RunningAudioSourceState); // // Add source to the running list and the mixing list // runningAudioSourceSocket.AddValue( audio_source, audio_source->CalculateAudioWeighting() ); if ( audio_source->GetAudioSourceMixPresence() != ManualAudioSourceMixPresence ) { mixingAudioSourceSocket.AddValue( audio_source, audio_source->GetAudioSourceMixPresence() ); } #ifdef LAB_ONLY sourceStartCount++; #endif } // //############################################################################# // StopRequest //############################################################################# // void L4AudioRenderer::StopRequest(L4AudioSource *audio_source) { Check(this); Check(audio_source); // //-------------------------------------------------------------------------- // Verify that the source is not stopped //-------------------------------------------------------------------------- // Verify(audio_source->GetAudioSourceState() != StoppedAudioSourceState); // //-------------------------------------------------------------------------- // If state of the source is dormant or suspended // Then set source to stop state and remove from dormant list //-------------------------------------------------------------------------- // if ( audio_source->GetAudioSourceState() == DormantAudioSourceState || audio_source->GetAudioSourceState() == SuspendedAudioSourceState ) { // // Set state of source to stopped // Verify that source is in dormant list // Remove source from dormant list // Verify that source is not in dormant list // audio_source->AssignAudioSourceState(StoppedAudioSourceState); #if DEBUG_LEVEL>0 { DormantSourceIterator iterator(&dormantAudioSourceSocket); Verify(iterator.IsPlugMember(audio_source) == True); } #endif PlugIterator remover(audio_source); remover.RemoveSocket(&dormantAudioSourceSocket); #if DEBUG_LEVEL>0 { DormantSourceIterator iterator(&dormantAudioSourceSocket); Verify(iterator.IsPlugMember(audio_source) == False); } #endif return; } // //-------------------------------------------------------------------------- // Stop the source //-------------------------------------------------------------------------- // AudioSourceStopMaintenance(audio_source); } // //############################################################################# // ExecuteImplementation //############################################################################# // void L4AudioRenderer::ExecuteImplementation( RendererComplexity complexity_update, RendererOrigin::InterestingEntityIterator *iterator ) { SET_AUDIO_RENDERER(); SET_AUDIO_RENDERER_EXECUTE(); Check(this); AudioRenderer::ExecuteImplementation(complexity_update, iterator); CalculateMix(); RunningSourceCheckup(); // RunningAudioSourceSort(); DormantSourceCheckup(); CLEAR_AUDIO_RENDERER_EXECUTE(); CLEAR_AUDIO_RENDERER(); } // //############################################################################# // CalculateMix //############################################################################# // void L4AudioRenderer::CalculateMix() { SET_AUDIO_CALCULATE_MIX(); // //-------------------------------------------------------------------------- // Is this the next calculate mix frame //-------------------------------------------------------------------------- // #if 0 if (nextCalculateMixFrame > GetAudioFrameCount()) { CLEAR_AUDIO_CALCULATE_MIX(); return; } nextCalculateMixFrame = GetAudioFrameCount() + DefaultAudioFrameDelay; #endif // //-------------------------------------------------------------------------- // Calculate the compression volume scaling // // For each source, calculate the amount to scale its volume by // according to those sources which have a higher mix presence //-------------------------------------------------------------------------- // { AudioControlValue compression_scale = 1.0f; AudioControlValue compression_effect = 0.0f; AudioSourceMixPresence current_mix_presence = MedAudioSourceMixPresence; MixingSourceIterator iterator(&mixingAudioSourceSocket); L4AudioSource *audio_source; Check(GetAudioHead()); Scalar comp_scale = GetAudioHead()->GetSourceCompressionScale(); Scalar comp_exp = GetAudioHead()->GetSourceCompressionExponent(); // // Get mix presence of the first source // if ((audio_source = iterator.GetCurrent()) != NULL) { Check(audio_source); current_mix_presence = audio_source->GetAudioSourceMixPresence(); } // //----------------------------------------------------------------------- // For each audio source, calculate the compression scale //----------------------------------------------------------------------- // while ((audio_source = iterator.ReadAndNext()) != NULL) { Check(audio_source); // //-------------------------------------------------------------------- // If the mix presence of this source is greater then the last // source then we have stepped to a lower mix presence. //-------------------------------------------------------------------- // if ( current_mix_presence != audio_source->GetAudioSourceMixPresence() ) { Verify( current_mix_presence < audio_source->GetAudioSourceMixPresence() ); current_mix_presence = audio_source->GetAudioSourceMixPresence(); // // Calculate the compression scale for this mix presence // // scale = 1 - ((comp_scale*x)^comp_exp) // compression_scale = 1.0f - pow(comp_scale * compression_effect, comp_exp); Verify(compression_scale >= 0.0f && compression_scale <= 1.0f); } // //-------------------------------------------------------------------- // Set the compression scale for this source //-------------------------------------------------------------------- // audio_source->SetVolumeCompressionScale(compression_scale); // //-------------------------------------------------------------------- // Calculate the compression effect for this source //-------------------------------------------------------------------- // AudioControlValue source_compression_effect; source_compression_effect = audio_source->CalculateSourceCompressionEffect(); Verify( source_compression_effect >= 0.0f && source_compression_effect <= 1.0f ); // //-------------------------------------------------------------------- // Update the compression effect for this mix presence //-------------------------------------------------------------------- // compression_effect = Max(compression_effect, source_compression_effect); Verify(compression_effect >= 0.0f && compression_effect <= 1.0f); #if 0 Tell( "compression_effect " << compression_effect << " : compression_scale " << compression_scale << "\n" ); #endif } } CLEAR_AUDIO_CALCULATE_MIX(); } // //############################################################################# // RunningSourceCheckup //############################################################################# // void L4AudioRenderer::RunningSourceCheckup() { SET_AUDIO_RUNNING_SOURCES(); // //-------------------------------------------------------------------------- // Iterate through all running sources //-------------------------------------------------------------------------- // ChainOf resort_socket(NULL); RunningSourceIterator running_iterator(&runningAudioSourceSocket); L4AudioSource *audio_source; while ((audio_source = running_iterator.GetCurrent()) != NULL) { Check(audio_source); // //----------------------------------------------------------------------- // If the source does not require maintenance then skip it //----------------------------------------------------------------------- // if (!audio_source->RequiresMaintenance(GetAudioHead())) { running_iterator.Next(); continue; } //Update object position // //----------------------------------------------------------------------- // If the source is clipped then either stop or suspend //----------------------------------------------------------------------- // if (audio_source->IsAudioSourceClipped(GetAudioHead())) { if (audio_source->GetAudioRenderType() == TransientAudioRenderType) { AudioSourceStopMaintenance(audio_source); } else { Verify( audio_source->GetAudioRenderType() == SustainedAudioRenderType ); AudioSourceSuspendMaintenance(audio_source); } continue; } // //----------------------------------------------------------------------- // If the source is finished playing then stop it //----------------------------------------------------------------------- // if (audio_source->IsFinishedPlaying()) { AudioSourceStopMaintenance(audio_source); continue; } // //----------------------------------------------------------------------- // Execute //----------------------------------------------------------------------- // SET_AUDIO_EXECUTE_SOURCES(); audio_source->Execute(); CLEAR_AUDIO_EXECUTE_SOURCES(); // //----------------------------------------------------------------------- // Add to resort list if weight has changed //----------------------------------------------------------------------- // if ( audio_source->CalculateAudioWeighting() != running_iterator.GetValue() ) { running_iterator.Remove(); resort_socket.Add(audio_source); } else { running_iterator.Next(); } } // //-------------------------------------------------------------------------- // Sort resort socket back into running socket //-------------------------------------------------------------------------- // ChainIteratorOf resort_iterator(&resort_socket); while ((audio_source = resort_iterator.ReadAndNext()) != NULL) { Check(audio_source); Verify(running_iterator.IsPlugMember(audio_source) == False); runningAudioSourceSocket.AddValue( audio_source, audio_source->CalculateAudioWeighting() ); } CLEAR_AUDIO_RUNNING_SOURCES(); } // //############################################################################# // DormantSourceCheckup //############################################################################# // void L4AudioRenderer::DormantSourceCheckup() { SET_AUDIO_DORMANT_SOURCES(); // //-------------------------------------------------------------------------- // Assemble the request socket //-------------------------------------------------------------------------- // VChainOf resume_request_socket(NULL, False); DormantSourceIterator dormant_iterator(&dormantAudioSourceSocket); L4AudioSource *audio_source; while ((audio_source = dormant_iterator.ReadAndNext()) != NULL) { Check(audio_source); // // If the suspend gate is not up then it can not resume // if (!audio_source->CanResume(GetAudioHead())) continue; // // If the audio source is clipped then it can not resume // if (audio_source->IsAudioSourceClipped(GetAudioHead())) continue; // // Add to request socket // resume_request_socket.AddValue( audio_source, audio_source->CalculateAudioWeighting() ); } // //-------------------------------------------------------------------------- // Iterate through request socket //-------------------------------------------------------------------------- // VChainIteratorOf resume_request_iterator(&resume_request_socket); while ((audio_source = resume_request_iterator.GetCurrent()) != NULL) { Check(audio_source); // // Request audio resources // /* AudioChannelSet channel_set_result;*/ SourceSet *source_result = audio_source->GetAudioChannelSet(); if (!RequestAudioResources(audio_source, source_result) ) { // Audio-dropout fix: hand back any partial acquisition (see // StartRequest) -- the source stays dormant and retries later. audio_source->ReleaseChannels(); break; } // // Remove from dormant list and request list // PlugIterator remover(audio_source); remover.RemoveSocket(&dormantAudioSourceSocket); AudioWeighting audio_weighting = resume_request_iterator.GetValue(); resume_request_iterator.Remove(); // // Update Spatial model // audio_source->UpdateSpatialModel(GetAudioHead()); // // Set channel set of source // Source resume implementation // Set state of source to running // audio_source->SetAudioChannelSet(*source_result); audio_source->ResumeImplementation(); audio_source->AssignAudioSourceState(RunningAudioSourceState); // // Put on running socket and mixing socket // runningAudioSourceSocket.AddValue(audio_source, audio_weighting); if ( audio_source->GetAudioSourceMixPresence() != ManualAudioSourceMixPresence ) { mixingAudioSourceSocket.AddValue( audio_source, audio_source->GetAudioSourceMixPresence() ); } } CLEAR_AUDIO_DORMANT_SOURCES(); } // //############################################################################# // AudioSourceStopMaintenance //############################################################################# // void L4AudioRenderer::AudioSourceStopMaintenance(L4AudioSource *source) { Check(source); Verify(source->GetAudioSourceState() == RunningAudioSourceState); #if DEBUG_LEVEL>0 { RunningSourceIterator iterator(&runningAudioSourceSocket); Verify(iterator.IsPlugMember(source) == True); } #endif source->StopImplementation(); source->AssignAudioSourceState(StoppedAudioSourceState); source->ReleaseChannels(); PlugIterator remover(source); remover.RemoveSocket(&runningAudioSourceSocket); remover.RemoveSocket(&mixingAudioSourceSocket); #if DEBUG_LEVEL>0 { RunningSourceIterator iterator(&runningAudioSourceSocket); Verify(iterator.IsPlugMember(source) == False); } #endif } // //############################################################################# // AudioSourceSuspendMaintenance //############################################################################# // void L4AudioRenderer::AudioSourceSuspendMaintenance(L4AudioSource *source) { Check(source); Verify(source->GetAudioRenderType() == SustainedAudioRenderType); Verify(source->GetAudioSourceState() == RunningAudioSourceState); #if DEBUG_LEVEL>0 { RunningSourceIterator iterator(&runningAudioSourceSocket); Verify(iterator.IsPlugMember(source) == True); } #endif source->SuspendImplementation(); source->AssignAudioSourceState(SuspendedAudioSourceState); source->ReleaseChannels(); PlugIterator remover(source); remover.RemoveSocket(&runningAudioSourceSocket); remover.RemoveSocket(&mixingAudioSourceSocket); #if DEBUG_LEVEL>0 { RunningSourceIterator iterator(&runningAudioSourceSocket); Verify(iterator.IsPlugMember(source) == False); } #endif dormantAudioSourceSocket.Add(source); } // //############################################################################# // RequestAudioResources //############################################################################# // Logical L4AudioRenderer::RequestAudioResources( L4AudioSource *audio_source, SourceSet *source_result ) { Check(this); Check(audio_source); Check(source_result); // //-------------------------------------------------------------------------- // This source must be within the culling volume //-------------------------------------------------------------------------- // if (audio_source->IsAudioSourceClipped(GetAudioHead())) return False; // //-------------------------------------------------------------------------- // Request audio channels for this source //-------------------------------------------------------------------------- // Logical resources_available; source_result = audio_source->GetAudioChannelSet(); Check(source_result); // #32 census: identify the requester while RequestAudioChannels runs, so a // failure can be attributed to a component CLASS without threading an // argument through the virtual (audio is main-thread only). extern int gBTAudioReqClass, gBTAudioReqVoices; gBTAudioReqClass = (int)audio_source->GetClassID(); gBTAudioReqVoices = audio_source->GetAudioVoiceCount(); resources_available = RequestAudioChannels( audio_source->GetAudioVoiceCount(), source_result ); // //-------------------------------------------------------------------------- // If audio channels are not available then check running sources for // lower weight sources //-------------------------------------------------------------------------- // if (!resources_available) { RunningSourceIterator iterator(&runningAudioSourceSocket); L4AudioSource *running_audio_source; AudioWeighting audio_source_weighting; iterator.Last(); running_audio_source = iterator.GetCurrent(); audio_source_weighting = audio_source->CalculateAudioWeighting(); // //----------------------------------------------------------------------- // while // request for channels is not satisfied and // there exist running sources that have lower weighting //----------------------------------------------------------------------- // while ( !resources_available && running_audio_source != NULL && audio_source_weighting < iterator.GetValue() // Actual value is inverted ) { Check(running_audio_source); Verify( running_audio_source->GetAudioSourcePriority() <= audio_source->GetAudioSourcePriority() ); // //-------------------------------------------------------------------- // Collect Statistics //-------------------------------------------------------------------- // #ifdef LAB_ONLY if ( running_audio_source->GetAudioSourcePriority() < audio_source->GetAudioSourcePriority() ) { resourceStealPriorityCount++; } else { Verify( running_audio_source->GetAudioSourcePriority() == audio_source->GetAudioSourcePriority() ); Warn( running_audio_source->CalculateSourceVolumeScale() >= audio_source->CalculateSourceVolumeScale() ); resourceStealVolumeCount++; } AudioTime duration_cutoff(0.1f); if ( running_audio_source->GetCurrentRunningTime() <= duration_cutoff ) { stealShortDurationCount++; } resourceStealCount++; #endif // //-------------------------------------------------------------------- // If running source is transient // Then stop the source // Else suspend the source //-------------------------------------------------------------------- // { // #32 census: a steal ends a RUNNING sound early -- count it // ungated so field logs show how hard the mixer is fighting. extern long gBTAudioSteals; ++gBTAudioSteals; } if ( running_audio_source->GetAudioRenderType() == TransientAudioRenderType ) { AudioSourceStopMaintenance(running_audio_source); } else { Verify( running_audio_source->GetAudioRenderType() == SustainedAudioRenderType ); AudioSourceSuspendMaintenance(running_audio_source); } // //-------------------------------------------------------------------- // Request resources again //-------------------------------------------------------------------- // resources_available = RequestAudioChannels( audio_source->GetAudioVoiceCount(), source_result ); iterator.Last(); running_audio_source = iterator.GetCurrent(); } // #32 census: the steal loop is done and the request is STILL // unsatisfied -- this sound is genuinely dropped. (The raw // "ACQUIRE FAILED" print fires once per attempt INSIDE the steal loop, // so its count wildly overstates real drops; this one does not.) if (!resources_available) { extern long gBTAudioTrueDrops; extern void BTAudioDropTally(int class_ID, int voices); ++gBTAudioTrueDrops; BTAudioDropTally((int)audio_source->GetClassID(), audio_source->GetAudioVoiceCount()); } } return resources_available; } // // OpenAL source pool (gitea #32) -- defined below, used here. // extern Logical BTAudioPoolAcquire(ALuint *out); extern void BTAudioPoolRelease(ALuint src); extern int BTAudioPoolSize(); extern int BTAudioPoolFree(); extern long BTAudioPoolReuses(); // //############################################################################# // RequestAudioChannels //############################################################################# // Logical L4AudioRenderer::RequestAudioChannels( int voices_requested, SourceSet *source_request ) { Check(this); Check(source_request); //Do we have enough? int requested = source_request->count; // SOURCE-POOL CENSUS (field 2026-07-23: "audio cutting in and out toward // the end of the match"). OpenAL's mixing capacity is finite; sources // release only at entity teardown, so long matches accumulate looping // occupants (wreck burn/smoke) until transients lose the voice fight. // Track generated/deleted/failed and print a 30s health line + EVERY // acquisition failure (rare + the smoking gun). extern long gBTAudioSourcesLive, gBTAudioAcquireFails; { static unsigned long s_censusAt = 0; unsigned long now_ms = GetTickCount(); if (now_ms - s_censusAt > 30000) { s_censusAt = now_ms; extern long gBTAudioSteals, gBTAudioTrueDrops; DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive << " pooled=" << BTAudioPoolSize() << " free=" << BTAudioPoolFree() << " reuses=" << BTAudioPoolReuses() << " acquireFails=" << gBTAudioAcquireFails << " steals=" << gBTAudioSteals << " drops=" << gBTAudioTrueDrops << std::endl << std::flush; extern void BTAudioDropCensus(); BTAudioDropCensus(); } } // Gitea #12: hard cap at the SourceSet capacity so alGenSources can never // write past sources[] (the death-crash heap overflow). count is set from // the streamed voiceCount and is normally already clamped in the // L4AudioSource ctor; this guards the acquisition site itself. if (requested > AUDIO_SOURCESET_CAPACITY) requested = AUDIO_SOURCESET_CAPACITY; alGetError(); // // SOURCE POOLING (gitea #32). This used to alGenSources() here and // alDeleteSources() in ReleaseSourceSet -- i.e. CREATE and DESTROY OpenAL // sources per sound event. Every 4.11.674 player log shows the result: // thousands of acquisition failures each (3031-6571 across five machines), // beginning ~10% into a match and never recovering, because a combat burst // churns straight through OpenAL's hard per-context source limit (256). // The 30s census reading `live=6` while the failure line read `live=256` // was the tell: the same counter, sampled between bursts -- churn, not a // steady leak. // // Sources are now generated ONCE and recycled through a free list, so // steady-state demand costs no allocation at all and the driver cap is // never approached. // for (int i = 0; i < requested; i++) { if (alIsSource(source_request->sources[i])) continue; // slot already holds one ALuint src = 0; if (!BTAudioPoolAcquire(&src)) { ++gBTAudioAcquireFails; // Rate-limited: the night-9 field logs carried 6.8k-19k of these per // player, and the count is NOISE -- the steal loop retries after every // failed attempt, so lines pile up per EVENT, and most events still // end in a successful steal. One line per 30s band keeps the signal // (the census carries the real counters: steals + true drops). extern int gBTAudioReqClass, gBTAudioReqVoices; static unsigned long s_failNoteAt = 0; unsigned long fail_now = GetTickCount(); if (fail_now - s_failNoteAt > 30000) { s_failNoteAt = fail_now; DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested << " reqClass=" << gBTAudioReqClass << " reqVoices=" << gBTAudioReqVoices << " live=" << gBTAudioSourcesLive << " pooled=" << BTAudioPoolSize() << " free=" << BTAudioPoolFree() << " fails=" << gBTAudioAcquireFails << ") -- saturated this instant; the steal loop decides what plays" << std::endl << std::flush; } return False; } source_request->sources[i] = src; } return True; // //---------------------------------------------------------------------- // Attempt to allocate channels from audio hardware //---------------------------------------------------------------------- // /* AudioChannel *channel; Logical success = False; AudioCard *front_card = GetFrontCard(); AudioCard *rear_card = GetRearCard(); Check(front_card); Check(rear_card); while (True) { if (channel_set_request->IsFrontLeftEnabled()) { if ((channel = front_card->RequestAudioChannel(voices_requested)) == NULL) break; Check(channel); channel_set_request->SetFrontLeft(channel); } if (channel_set_request->IsFrontRightEnabled()) { if ((channel = front_card->RequestAudioChannel(voices_requested)) == NULL) break; Check(channel); channel_set_request->SetFrontRight(channel); } if (channel_set_request->IsRearLeftEnabled()) { if ((channel = rear_card->RequestAudioChannel(voices_requested)) == NULL) break; Check(channel); channel_set_request->SetRearLeft(channel); } if (channel_set_request->IsRearRightEnabled()) { if ((channel = rear_card->RequestAudioChannel(voices_requested)) == NULL) break; Check(channel); channel_set_request->SetRearRight(channel); } success = True; break; } // //---------------------------------------------------------------------- // If did not succeed then release allocated channels //---------------------------------------------------------------------- // if (!success) { channel_set_request->ReleaseAll(); return False; } // //---------------------------------------------------------------------- // else, return allocated channels //---------------------------------------------------------------------- // return True;*/ } long gBTAudioSourcesLive = 0; long gBTAudioAcquireFails = 0; // // #32 census v2. The night-9 field logs proved the FIRST generation of these // counters mislead under pressure: "ACQUIRE FAILED ... free=0" reads as // permanent retention, but free is 0 at the instant of any failed acquire BY // DEFINITION -- the 30s census showed free back at ~230 between bursts, i.e. // the pool cycles and the mixer is simply saturated DURING combat. (Same // counter-sampling trap as the original live=256-vs-live=6, second offence.) // These count what actually matters: // gBTAudioSteals -- a running sound was ended early to service a new one // gBTAudioTrueDrops -- the steal loop ran dry and the sound NEVER PLAYED // plus a per-component-class tally of the dropped, so the next field log names // the class that saturates the mixer instead of leaving it to inference. // int gBTAudioReqClass = -1; int gBTAudioReqVoices = 0; long gBTAudioSteals = 0; long gBTAudioTrueDrops = 0; struct BTAudioDropBin { int classID; int voices; long count; }; static BTAudioDropBin gBTAudioDropBins[12]; static int gBTAudioDropBinCount = 0; void BTAudioDropTally(int class_ID, int voices) { for (int i = 0; i < gBTAudioDropBinCount; ++i) { if (gBTAudioDropBins[i].classID == class_ID && gBTAudioDropBins[i].voices == voices) { ++gBTAudioDropBins[i].count; return; } } if (gBTAudioDropBinCount < 12) { gBTAudioDropBins[gBTAudioDropBinCount].classID = class_ID; gBTAudioDropBins[gBTAudioDropBinCount].voices = voices; gBTAudioDropBins[gBTAudioDropBinCount].count = 1; ++gBTAudioDropBinCount; } } // One line under the 30s census, only when something was dropped since boot: // which component classes lost sounds, and how many. void BTAudioDropCensus() { if (gBTAudioDropBinCount == 0) return; DEBUG_STREAM << "[audio] dropped by class:"; for (int i = 0; i < gBTAudioDropBinCount; ++i) DEBUG_STREAM << " {class " << gBTAudioDropBins[i].classID << " x" << gBTAudioDropBins[i].voices << "v: " << gBTAudioDropBins[i].count << "}"; DEBUG_STREAM << std::endl << std::flush; } //############################################################################# // OpenAL SOURCE POOL (gitea #32) //############################################################################# // // The renderer used to create an AL source per sound event and destroy it on // release. OpenAL sources are a scarce driver resource -- OpenAL Soft caps a // context at 256 -- and creating them is not cheap, so combat bursts ran the // context dry. Field evidence (4.11.674, five machines): 3031-6571 acquisition // failures per player, first failure ~10% into a match, still failing at 97%. // // Sources are now generated once, on demand, up to kAudioPoolCap and then // RECYCLED: release scrubs the source and returns it to the free list instead // of deleting it. Steady-state play performs no AL allocation at all. // // THE SCRUB IS LOAD-BEARING. A recycled source carries whatever the previous // owner set on it, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327). // Hand a looping source to a one-shot and it plays forever -- exactly the // "sound stuck looping" family (#51, #5). Every reusable property is reset // here, at the single point where sources change owner. // // Not locked: the audio renderer runs on the main thread (only the network RX // socket has its own). If that ever changes, this needs a mutex. // // The pool's ceiling. DEFAULT: just under OpenAL Soft's 256-source context // default. BT_AUDIO_SOURCES= raises the AL context budget at Initialize // (above); the pool cap now FOLLOWS it -- before this, the env raised the // context and the pool still stopped at 240, so the experiment was impossible // to run in the field. kAudioPoolMax bounds the static free-list array. static const int kAudioPoolMax = 1024; static int kAudioPoolCap = 240; static int BTAudioPoolCapResolve() { static int s_done = 0; if (!s_done) { s_done = 1; const char *sv = getenv("BT_AUDIO_SOURCES"); if (sv != 0) { int n = atoi(sv); if (n >= 64 && n <= 4096) { // stay under the context grant with a small reserve kAudioPoolCap = (n - 16 < kAudioPoolMax) ? (n - 16) : kAudioPoolMax; DEBUG_STREAM << "[audio] source pool cap follows BT_AUDIO_SOURCES: " << kAudioPoolCap << std::endl << std::flush; } } } return kAudioPoolCap; } // // PEAK DEMAND is set by how many audio COMPONENTS are alive, not by how many // sounds are audible: each component reserves a SourceSet of up to // AUDIO_SOURCESET_CAPACITY (25) voices and holds them until it is released. // Measured high-water: 138 solo vs one enemy, 149 across two nodes. That grows // with player count, so the cap is deliberately near the driver ceiling and the // pool reports its high-water mark once, to size this from FIELD logs rather // than from a guess. Growth also stops on its own if a driver offers fewer // sources than the cap -- alGenSources failing simply ends growth and the pool // recycles what it has. static ALuint gAudioPoolFree[kAudioPoolMax]; static int gAudioPoolFreeCount = 0; // entries in gAudioPoolFree static int gAudioPoolTotal = 0; // sources ever generated (<= cap) static long gAudioPoolReuses = 0; // diagnostics int BTAudioPoolSize() { return gAudioPoolTotal; } int BTAudioPoolFree() { return gAudioPoolFreeCount; } long BTAudioPoolReuses() { return gAudioPoolReuses; } // // Reset a source to a known-neutral state so nothing carries across owners. // static void BTAudioScrubSource(ALuint src) { ALint state = AL_STOPPED; alGetSourcei(src, AL_SOURCE_STATE, &state); if (state == AL_PLAYING || state == AL_PAUSED) alSourceStop(src); alSourcei(src, AL_BUFFER, 0); // detach (no queued buffers here) alSourcei(src, AL_LOOPING, AL_FALSE); // <-- the stuck-loop guard alSourcef(src, AL_GAIN, 1.0f); alSourcef(src, AL_PITCH, 1.0f); alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE); alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f); alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f); alSourcef(src, AL_MIN_GAIN, 0.0f); alSourcef(src, AL_MAX_GAIN, 1.0f); alGetError(); // swallow any property complaint } // // Hand out a source: recycle first, generate only when the pool has never been // that large. False = genuinely out (peak concurrent demand exceeded the cap). // Logical BTAudioPoolAcquire(ALuint *out) { Check_Pointer(out); while (gAudioPoolFreeCount > 0) { ALuint src = gAudioPoolFree[--gAudioPoolFreeCount]; if (alIsSource(src)) // paranoia: a context reset invalidates names { ++gAudioPoolReuses; *out = src; return True; } --gAudioPoolTotal; // stale name: forget it } if (gAudioPoolTotal >= BTAudioPoolCapResolve()) return False; ALuint src = 0; alGetError(); alGenSources(1, &src); if (alGetError() != AL_NO_ERROR || !alIsSource(src)) return False; // driver said no before our cap ++gAudioPoolTotal; ++gBTAudioSourcesLive; { // One line per new high-water band, so a field log shows how close a // real match gets to the ceiling without spamming. static int s_notified = 0; if (gAudioPoolTotal >= s_notified + 25) { s_notified = gAudioPoolTotal; DEBUG_STREAM << "[audio] source pool high-water: " << gAudioPoolTotal << " of " << kAudioPoolCap << std::endl << std::flush; } } *out = src; return True; } // // Take a source back. Scrubbed and parked, NOT deleted. // void BTAudioPoolRelease(ALuint src) { if (!alIsSource(src)) return; BTAudioScrubSource(src); if (gAudioPoolFreeCount < kAudioPoolMax) { gAudioPoolFree[gAudioPoolFreeCount++] = src; return; } alDeleteSources(1, &src); // cannot happen (max == array size) --gAudioPoolTotal; --gBTAudioSourcesLive; } void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet) { // Audio-dropout fix: the old bulk alDeleteSources(count, sources) is // ATOMIC per the AL spec -- ONE invalid name in the array (an empty slot // of a partial set, or the old -1 sentinel on a double release) and // NOTHING is deleted. After the first pool-exhaustion event every // partial release leaked its real sources and the pool never recovered // ("audio cutting in and out toward the end of the match"). Delete each // valid source individually and park the slot at 0 (never a valid name). // SOURCE POOLING (#32): hand each source back to the free list instead of // destroying it. BTAudioPoolRelease stops it, detaches its buffer and // clears AL_LOOPING before parking it, so the next owner starts clean. for (int i = 0; i < sourceSet.count; i++) { if (sourceSet.sources[i] != 0) BTAudioPoolRelease(sourceSet.sources[i]); sourceSet.sources[i] = 0; // 0 is never a valid AL name } } //~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~ #if defined(TRACE_AUDIO_RENDERER_RUNNING_SOURCES) BitTrace Audio_Renderer_Running_Sources("Audio Renderer Running Sources"); #endif #if defined(TRACE_AUDIO_RENDERER_RUNNING_SORT) BitTrace Audio_Renderer_Running_Sort("Audio Renderer Running Sort"); #endif #if defined(TRACE_AUDIO_RENDERER_CALCULATE_MIX) BitTrace Audio_Renderer_Calculate_Mix("Audio Renderer Calculate Mix"); #endif #if defined(TRACE_AUDIO_RENDERER_EXECUTE_SOURCES) BitTrace Audio_Renderer_Execute_Sources("Audio Renderer Execute Sources"); #endif #if defined(TRACE_AUDIO_RENDERER_DORMANT_SOURCES) BitTrace Audio_Renderer_Dormant_Sources("Audio Renderer Dormant Sources"); #endif // //############################################################################# // Master-volume runtime control (issue #26, 2026-07-23). The pod had a // physical volume knob on the operator side; desktop players had NO way to // duck the (hot) game audio under voice chat short of the Windows mixer. // The -/= keys step the OpenAL listener gain (the master scale for every // source); the value persists in content\volume.cfg (CWD) and reloads at // the next boot (BT_AUDIO_VOLUME env still wins when set). //############################################################################# // float gBTMasterVolume = 0.6f; void BTAudioMasterVolumeStep(int direction) { gBTMasterVolume += (direction > 0) ? 0.05f : -0.05f; if (gBTMasterVolume < 0.0f) gBTMasterVolume = 0.0f; if (gBTMasterVolume > 1.5f) gBTMasterVolume = 1.5f; alListenerf(AL_GAIN, gBTMasterVolume); FILE *cfg = fopen("volume.cfg", "wt"); if (cfg != NULL) { fprintf(cfg, "%.2f\n", gBTMasterVolume); fclose(cfg); } DEBUG_STREAM << "[audio] master volume " << (int)(gBTMasterVolume * 100.0f + 0.5f) << "%" << std::endl << std::flush; }