17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-28 13:27:30 +01:00

Broadcast radio receiver in the cab

Plays internet radio streams (http, mp3) through a positional emitter in the cab, with analog tuning, inter-station noise and two speaker profiles. Stations are listed in fmradio.txt, cab controls are defined with fmradio entries in the .mmd file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IwanXXI
2026-10-10 21:22:39 +02:00
committed by Jano211
parent c7f9796387
commit b4b1e6c32f
17 changed files with 7509 additions and 0 deletions

View File

@@ -154,6 +154,8 @@ set(SOURCES
"model/vertex.cpp"
"audio/audio.cpp"
"audio/audiorenderer.cpp"
"audio/radiostream.cpp"
"audio/fmradio.cpp"
"utilities/motiontelemetry.cpp"
"application/uitranscripts.cpp"
"world/station.cpp"

View File

@@ -274,6 +274,8 @@ openal_renderer::~openal_renderer() {
if( m_alcEventCallbackSOFT != nullptr ) { m_alcEventCallbackSOFT( nullptr, nullptr ); } // stop callbacks before teardown
if( m_context != nullptr ) { m_fmradio.release(); } // the receiver owns implementation-side resources, which have to go before the context does
::alcMakeContextCurrent( nullptr );
if( m_context != nullptr ) { ::alcDestroyContext( m_context ); }
@@ -487,6 +489,11 @@ openal_renderer::update( double const Deltatime ) {
}
}
// update streamed sound
if( m_ready ) {
m_fmradio.update( Deltatime, cached_camerapos );
}
// reset potentially used volume change flag
audio::event_volume_change = false;

View File

@@ -10,6 +10,7 @@ http://mozilla.org/MPL/2.0/.
#pragma once
#include "audio/audio.h"
#include "audio/fmradio.h"
#include "model/ResourceManager.h"
#include "application/uitranscripts.h"
@@ -121,6 +122,8 @@ public:
void erase( sound_source const *Controller );
void update( double const Deltatime );
// provides access to the broadcast radio receiver
audio::fm_radio &fmradio() { return m_fmradio; }
glm::dvec3 cached_camerapos;
@@ -142,6 +145,7 @@ private:
buffer_manager m_buffers;
source_list m_sources;
source_sequence m_sourcespares;
audio::fm_radio m_fmradio; // streamed sound, maintained separately from regular emitters
// OpenAL Soft extension entry points, resolved at runtime via al(c)GetProcAddress
LPALDEFERUPDATESSOFT m_alDeferUpdatesSOFT { nullptr };

640
audio/fmradio.cpp Normal file
View File

@@ -0,0 +1,640 @@
/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
#include "stdafx.h"
#include "audio/fmradio.h"
#include "utilities/Globals.h"
#include "utilities/Logs.h"
namespace audio
{
namespace
{
auto const EU07_FMRADIO_BUFFERCOUNT{8}; // number of sound buffers owned by the receiver
auto const EU07_FMRADIO_QUEUELENGTH{6}; // max number of sound buffers waiting for playback. kept short, so the sound follows the knobs without noticeable delay
auto const EU07_FMRADIO_BUFFERSPERSECOND{40}; // inverse of the length of a single sound buffer
// sample rate of generated audio. it has to stay the same for all buffers queued in the emitter, so station audio is converted to it if needed
unsigned int const EU07_FMRADIO_SAMPLERATE{44100};
auto const EU07_FMRADIO_PREBUFFERLENGTH{1.0}; // seconds of station audio required to let it through
auto const EU07_FMRADIO_UNATTENDEDLIMIT{1.0}; // seconds without contact from the owner after which the receiver turns itself off
auto const EU07_FMRADIO_CAPTURERANGE{0.15f}; // MHz, max distance from the station frequency at which the station can be heard
auto const EU07_FMRADIO_CLEANRANGE{0.03f}; // MHz, max distance from the station frequency at which the reception is undisturbed
auto const EU07_FMRADIO_SETTLETIME{0.1}; // seconds the tuning knob has to spend at a station before the receiver connects with its stream
// seconds the connection with a station is kept after the tuning knob moves away from it.
// allows to pass a station, hear where it was and come back to it without waiting for another connection
auto const EU07_FMRADIO_LINGERTIME{8.0};
auto const EU07_FMRADIO_CARRIERNOISE{0.02f}; // fraction of the noise left when the receiver is locked on a station
// the receiver makes a short electric crackle when it locks on a station or lets go of it
auto const EU07_FMRADIO_CRACKLELEVEL{0.25f};
auto const EU07_FMRADIO_CRACKLEDENSITY{0.12f}; // fraction of samples which carry a spike
auto const EU07_FMRADIO_CRACKLEDECAY{0.008f}; // seconds
auto const EU07_FMRADIO_CRACKLELENGTH{0.04f}; // seconds
auto const EU07_FMRADIO_NOISELEVEL{0.3f}; // amplitude of the noise heard between the stations
// level of the receiver signal at the input of the simulated amplifier. modern stations broadcast at full scale most of the time,
// so without some headroom the amplifier and the filters boosting parts of the spectrum would be driven into heavy distortion
auto const EU07_FMRADIO_HEADROOM{0.5f};
auto const EU07_FMRADIO_LIMITERKNEE{0.9f}; // output level above which the peaks are rounded off
// gain of the emitter with the volume knob turned all the way up. regular cab sounds are short and loud,
// while a broadcast played at nominal gain gets lost in the noise of a running vehicle
auto const EU07_FMRADIO_LOUDNESS{3.f};
auto const EU07_FMRADIO_PI{3.14159265358979f};
std::string trim(std::string const &Text)
{
auto const first{Text.find_first_not_of(" \t\r\n")};
if (first == std::string::npos)
{
return {};
}
auto const last{Text.find_last_not_of(" \t\r\n")};
return Text.substr(first, last - first + 1);
}
} // namespace
// filter designs follow the audio eq cookbook by Robert Bristow-Johnson
fm_radio::biquad fm_radio::biquad::highpass(float const Rate, float const Frequency, float const Q)
{
auto const omega{2.f * EU07_FMRADIO_PI * std::min(Frequency, Rate * 0.45f) / Rate};
auto const cosine{std::cos(omega)};
auto const alpha{std::sin(omega) / (2.f * Q)};
auto const a0{1.f + alpha};
biquad filter;
filter.b0 = (1.f + cosine) * 0.5f / a0;
filter.b1 = -(1.f + cosine) / a0;
filter.b2 = filter.b0;
filter.a1 = -2.f * cosine / a0;
filter.a2 = (1.f - alpha) / a0;
return filter;
}
fm_radio::biquad fm_radio::biquad::lowpass(float const Rate, float const Frequency, float const Q)
{
auto const omega{2.f * EU07_FMRADIO_PI * std::min(Frequency, Rate * 0.45f) / Rate};
auto const cosine{std::cos(omega)};
auto const alpha{std::sin(omega) / (2.f * Q)};
auto const a0{1.f + alpha};
biquad filter;
filter.b0 = (1.f - cosine) * 0.5f / a0;
filter.b1 = (1.f - cosine) / a0;
filter.b2 = filter.b0;
filter.a1 = -2.f * cosine / a0;
filter.a2 = (1.f - alpha) / a0;
return filter;
}
// Gain is in decibels
fm_radio::biquad fm_radio::biquad::peak(float const Rate, float const Frequency, float const Q, float const Gain)
{
auto const amplitude{std::pow(10.f, Gain / 40.f)};
auto const omega{2.f * EU07_FMRADIO_PI * std::min(Frequency, Rate * 0.45f) / Rate};
auto const cosine{std::cos(omega)};
auto const alpha{std::sin(omega) / (2.f * Q)};
auto const a0{1.f + alpha / amplitude};
biquad filter;
filter.b0 = (1.f + alpha * amplitude) / a0;
filter.b1 = -2.f * cosine / a0;
filter.b2 = (1.f - alpha * amplitude) / a0;
filter.a1 = filter.b1;
filter.a2 = (1.f - alpha / amplitude) / a0;
return filter;
}
void fm_radio::power(bool const State)
{
std::scoped_lock lock{m_controlmutex};
m_control.power = State;
m_control.unattended = 0.0;
}
// sets frequency the receiver is tuned to, in MHz
void fm_radio::tune(float const Frequency)
{
std::scoped_lock lock{m_controlmutex};
m_control.frequency = std::clamp(Frequency, EU07_FMRADIO_BANDSTART, EU07_FMRADIO_BANDEND);
}
// sets position of the volume knob, in range 0-1
void fm_radio::volume(float const Volume)
{
std::scoped_lock lock{m_controlmutex};
m_control.volume = std::clamp(Volume, 0.f, 1.f);
}
void fm_radio::type(fmradio_type const Type)
{
std::scoped_lock lock{m_controlmutex};
m_control.type = Type;
}
// sets location of the receiver in the simulation region space, and audibility parameters at the current position of the listener
void fm_radio::placement(glm::dvec3 const &Location, float const Soundproofing, float const Range)
{
std::scoped_lock lock{m_controlmutex};
m_control.location = Location;
m_control.soundproofing = Soundproofing;
m_control.range = std::max(1.f, Range);
}
// returns frequency of the first station on the list, or middle of the band if there's none
float fm_radio::default_frequency()
{
load_stations();
return m_stations.empty() ? (EU07_FMRADIO_BANDSTART + EU07_FMRADIO_BANDEND) * 0.5f : m_stations.front().frequency;
}
// imports the list of stations. each line of the file holds name of a station, address of its stream and its frequency, separated with semicolons
void fm_radio::load_stations()
{
std::scoped_lock lock{m_stationsmutex};
if (m_stationsloaded)
{
return;
}
m_stationsloaded = true;
std::ifstream input{Global.fmradio_stations};
if (false == input.is_open())
{
// NOTE: the list isn't a part of the simulator and it's up to the user to provide one, so its absence isn't an error
WriteLog("fm radio: no station list found at \"" + Global.fmradio_stations + "\", the receiver will pick up only noise", logtype::sound);
return;
}
std::string line;
auto linenumber{0};
while (std::getline(input, line))
{
++linenumber;
if (linenumber == 1 && line.rfind("\xEF\xBB\xBF", 0) == 0)
{
line.erase(0, 3); // byte order mark
}
line = trim(line);
if (line.empty() || line.front() == '#' || line.rfind("//", 0) == 0)
{
continue;
}
// NOTE: the address can potentially contain semicolons, so we take the outermost ones as separators
auto const nameend{line.find(';')};
auto const frequencystart{line.rfind(';')};
if (nameend == std::string::npos || frequencystart == nameend)
{
ErrorLog("fm radio: malformed entry in line " + std::to_string(linenumber) + " of the station list, expected \"name; address; frequency\"", logtype::sound);
continue;
}
station entry;
entry.name = trim(line.substr(0, nameend));
entry.url = trim(line.substr(nameend + 1, frequencystart - nameend - 1));
auto frequency{trim(line.substr(frequencystart + 1))};
std::replace(std::begin(frequency), std::end(frequency), ',', '.');
entry.frequency = static_cast<float>(std::atof(frequency.c_str()));
if (entry.url.empty() || entry.frequency < EU07_FMRADIO_BANDSTART || entry.frequency > EU07_FMRADIO_BANDEND)
{
ErrorLog("fm radio: station \"" + entry.name + "\" in line " + std::to_string(linenumber) + " of the station list has no address or its frequency is outside of 87.5-108 MHz range", logtype::sound);
continue;
}
m_stations.emplace_back(entry);
}
WriteLog(m_stations.empty() ? "fm radio: the station list is empty, the receiver will pick up only noise" : "fm radio: " + std::to_string(m_stations.size()) + " station(s) on the list", logtype::sound);
}
// returns index of the station which can be received at specified frequency, or -1
int fm_radio::find_station(float const Frequency) const
{
auto result{-1};
auto distance{EU07_FMRADIO_CAPTURERANGE};
for (std::size_t index{0}; index < m_stations.size(); ++index)
{
auto const stationdistance{std::abs(m_stations[index].frequency - Frequency)};
if (stationdistance < distance)
{
distance = stationdistance;
result = static_cast<int>(index);
}
}
return result;
}
// selects the station to receive, taking into account movement of the tuning knob
void fm_radio::update_tuning(control_block const &Control, double const Deltatime)
{
auto const candidate{find_station(Control.frequency)};
if (candidate != m_candidate)
{
m_candidate = candidate;
m_candidatetime = 0.0;
}
else
{
m_candidatetime += Deltatime;
}
// NOTE: connection is made after the knob stays at the station for a moment, to skip stations passed at full speed
if (candidate != -1 && candidate != m_station && m_candidatetime >= EU07_FMRADIO_SETTLETIME)
{
m_station = candidate;
WriteLog("fm radio: tuned to \"" + m_stations[m_station].name + "\", " + std::to_string(m_stations[m_station].frequency) + " MHz", logtype::sound);
m_stream.open(m_stations[m_station].url);
m_buffering = true;
m_inputphase = 0.0;
m_inputsamples = {0, 0};
}
if (m_station != -1)
{
if (m_station == candidate)
{
m_lingertime = 0.0;
}
else
{
// the knob has left the station we were connected with. the connection is kept for a while in case the knob comes back
m_lingertime += Deltatime;
if (m_lingertime > EU07_FMRADIO_LINGERTIME)
{
m_stream.close();
m_station = -1;
}
}
}
}
// prepares speaker simulation for specified receiver
void fm_radio::setup_speaker(fmradio_type const Type)
{
m_speakertype = Type;
m_speaker.clear();
auto const rate{static_cast<float>(EU07_FMRADIO_SAMPLERATE)};
switch (Type)
{
case fmradio_type::cheap:
{
// tiny speaker in a thin plastic case: no bass to speak of, harsh midrange, overdriven amplifier
m_speaker.emplace_back(biquad::highpass(rate, 420.f, 0.8f));
m_speaker.emplace_back(biquad::highpass(rate, 300.f, 0.6f));
m_speaker.emplace_back(biquad::peak(rate, 1900.f, 1.4f, 7.f));
m_speaker.emplace_back(biquad::lowpass(rate, 3400.f, 0.9f));
m_speakerdrive = 2.2f;
m_speakergain = 0.75f;
break;
}
case fmradio_type::wooden:
{
// large speaker in a wooden cabinet: full, slightly boomy lows and soft, rolled off highs
m_speaker.emplace_back(biquad::highpass(rate, 65.f, 0.7f));
m_speaker.emplace_back(biquad::peak(rate, 130.f, 0.9f, 5.f));
m_speaker.emplace_back(biquad::peak(rate, 2400.f, 0.8f, -2.5f));
m_speaker.emplace_back(biquad::lowpass(rate, 4200.f, 0.6f));
m_speakerdrive = 1.f;
m_speakergain = 1.1f;
break;
}
}
}
// fills the staging area with next piece of the receiver output
void fm_radio::generate_chunk(control_block const &Control, std::size_t const Samplecount)
{
m_chunk.assign(Samplecount, 0);
m_mix.resize(Samplecount);
// strength of the station signal at the current position of the tuning knob. it drops with the distance from the station frequency
auto carrier{0.f};
if (m_candidate != -1)
{
auto const distance{std::abs(m_stations[m_candidate].frequency - Control.frequency)};
auto const detune{std::clamp((distance - EU07_FMRADIO_CLEANRANGE) / (EU07_FMRADIO_CAPTURERANGE - EU07_FMRADIO_CLEANRANGE), 0.f, 1.f)};
carrier = 1.f - detune * detune * (3.f - 2.f * detune);
}
// the moment the receiver locks on a station or loses it is marked with a crackle.
// together with the drop of the noise it tells there's a station, even if it's passed too quickly to make out anything else
// NOTE: the thresholds are set apart, so the receiver doesn't keep thumping when the knob is left right at the edge
auto const locked{m_locked ? carrier > 0.4f : carrier >= 0.6f};
if (locked != m_locked)
{
m_locked = locked;
m_thumpposition = 0;
}
// station audio
// NOTE: it's available only if we're connected with the station at the knob, the connection may be still in progress or kept with another station
auto reception{0.f};
if (m_station != -1 && m_station == m_candidate)
{
auto const streamrate{m_stream.rate()};
if (m_buffering && streamrate > 0 && m_stream.available() >= static_cast<std::size_t>(streamrate * EU07_FMRADIO_PREBUFFERLENGTH))
{
m_buffering = false;
}
if (false == m_buffering)
{
// station audio is converted to our sample rate with linear interpolation, which is plenty for a radio speaker
auto const step{static_cast<double>(streamrate) / EU07_FMRADIO_SAMPLERATE};
m_input.assign(static_cast<std::size_t>(m_inputphase + step * Samplecount), 0);
if (m_stream.read(m_input.data(), m_input.size()) < m_input.size())
{
// we ran out of data. the missing part of the chunk is already silent, and the station is put on hold until the reserve is rebuilt
m_buffering = true;
}
std::size_t inputindex{0};
for (std::size_t index{0}; index < Samplecount; ++index)
{
m_inputphase += step;
while (m_inputphase >= 1.0)
{
m_inputsamples[0] = m_inputsamples[1];
m_inputsamples[1] = (inputindex < m_input.size() ? m_input[inputindex++] : 0);
m_inputphase -= 1.0;
}
m_chunk[index] = static_cast<std::int16_t>(m_inputsamples[0] + (m_inputsamples[1] - m_inputsamples[0]) * m_inputphase);
}
reception = carrier;
}
}
// mix of the station, the noise and the crackle
auto const receptionstep{(reception - m_reception) / static_cast<float>(Samplecount)};
auto const carrierstep{(carrier - m_carrier) / static_cast<float>(Samplecount)};
auto const cracklelength{static_cast<int>(EU07_FMRADIO_CRACKLELENGTH * EU07_FMRADIO_SAMPLERATE)};
for (std::size_t index{0}; index < Samplecount; ++index)
{
// NOTE: the clamps matter, accumulated rounding errors can push the values slightly below zero and the square root further down wouldn't like it
m_reception = std::clamp(m_reception + receptionstep, 0.f, 1.f);
m_carrier = std::clamp(m_carrier + carrierstep, 0.f, 1.f);
// white noise from a xorshift generator
m_noisestate ^= m_noisestate << 13;
m_noisestate ^= m_noisestate >> 17;
m_noisestate ^= m_noisestate << 5;
auto const noise{static_cast<float>(m_noisestate) * (2.f / 4294967296.f) - 1.f};
auto signal{m_chunk[index] * (1.f / 32768.f)};
if (m_reception < 0.999f)
{
// an fm receiver which is off the station frequency cuts off peaks of the signal, the further the stronger
auto const ceiling{0.05f + 0.95f * m_reception};
signal = std::clamp(signal * (1.f + 2.f * (1.f - m_reception)), -ceiling, ceiling);
}
// the noise is pushed down by the station signal itself, whether there's anything to hear on it or not
auto const noisegain{std::max((1.f - m_carrier) * (1.f - m_carrier), EU07_FMRADIO_CARRIERNOISE)};
auto sample{signal * std::sqrt(m_reception) + noise * noisegain * EU07_FMRADIO_NOISELEVEL};
if (m_thumpposition >= 0)
{
// sparse spikes of random polarity, fading out quickly
auto const time{static_cast<float>(m_thumpposition) / EU07_FMRADIO_SAMPLERATE};
if (std::abs(noise) > 1.f - EU07_FMRADIO_CRACKLEDENSITY)
{
sample += (noise < 0.f ? -EU07_FMRADIO_CRACKLELEVEL : EU07_FMRADIO_CRACKLELEVEL) * std::exp(-time / EU07_FMRADIO_CRACKLEDECAY);
}
if (++m_thumpposition >= cracklelength)
{
m_thumpposition = -1;
}
}
m_mix[index] = sample;
}
m_reception = reception;
m_carrier = carrier;
// speaker
if (false == Global.fmradio_cleansound)
{
if (m_speaker.empty() || m_speakertype != Control.type)
{
setup_speaker(Control.type);
}
for (auto &sample : m_mix)
{
// amplifier, with soft saturation
sample = std::tanh(sample * EU07_FMRADIO_HEADROOM * m_speakerdrive);
for (auto &filter : m_speaker)
{
sample = filter.process(sample);
}
sample *= m_speakergain;
}
// safety net, an invalid value stuck in a filter would silence the receiver for good
for (auto &filter : m_speaker)
{
if (false == std::isfinite(filter.z1) || false == std::isfinite(filter.z2))
{
filter.z1 = 0.f;
filter.z2 = 0.f;
std::fill(std::begin(m_mix), std::end(m_mix), 0.f);
}
}
}
for (std::size_t index{0}; index < Samplecount; ++index)
{
// peaks which still go over the top are rounded off, as hard clipping sounds much worse
auto sample{m_mix[index]};
auto const level{std::abs(sample)};
if (level > EU07_FMRADIO_LIMITERKNEE)
{
auto const limited{EU07_FMRADIO_LIMITERKNEE + (1.f - EU07_FMRADIO_LIMITERKNEE) * std::tanh((level - EU07_FMRADIO_LIMITERKNEE) / (1.f - EU07_FMRADIO_LIMITERKNEE))};
sample = (sample < 0.f ? -limited : limited);
}
m_chunk[index] = static_cast<std::int16_t>(sample * 32767.f);
}
}
// updates state of the receiver. expects active audio context
void fm_radio::update(double const Deltatime, glm::dvec3 const &Listener)
{
control_block control;
{
std::scoped_lock lock{m_controlmutex};
if (m_control.unattended > EU07_FMRADIO_UNATTENDEDLIMIT)
{
// the owner is gone, or has more important things to do
m_control.power = false;
}
// NOTE: the time is added after the check, so a single long frame doesn't count as a lack of contact
m_control.unattended += Deltatime;
control = m_control;
}
// pass messages from the stream worker to the log
radio_stream::message message;
while (m_stream.fetch_message(message))
{
if (message.second)
{
ErrorLog(message.first, logtype::sound);
}
else
{
WriteLog(message.first, logtype::sound);
}
}
if (false == control.power)
{
if (m_active)
{
m_stream.close();
reset_emitter();
m_station = -1;
m_candidate = -1;
m_active = false;
}
return;
}
if (false == m_active)
{
// switched on
load_stations();
m_reception = 0.f;
m_carrier = 0.f;
m_locked = false;
m_thumpposition = -1;
m_active = true;
}
update_tuning(control, Deltatime);
if (m_source == audio::null_resource && false == create_emitter())
{
return;
}
// feed the emitter with generated audio
auto queuedcount{reclaim_buffers()};
auto const chunksize{static_cast<std::size_t>(EU07_FMRADIO_SAMPLERATE / EU07_FMRADIO_BUFFERSPERSECOND)};
while (queuedcount < EU07_FMRADIO_QUEUELENGTH && false == m_sparebuffers.empty())
{
generate_chunk(control, chunksize);
auto const buffer{m_sparebuffers.back()};
m_sparebuffers.pop_back();
::alBufferData(buffer, AL_FORMAT_MONO16, m_chunk.data(), static_cast<ALsizei>(m_chunk.size() * sizeof(std::int16_t)), static_cast<ALsizei>(EU07_FMRADIO_SAMPLERATE));
::alSourceQueueBuffers(m_source, 1, &buffer);
++queuedcount;
}
// location
auto const offset{glm::vec3{control.location - Listener}};
::alSourcefv(m_source, AL_POSITION, glm::value_ptr(offset));
// gain
// NOTE: the knob position is squared to approximate the response of an audio taper potentiometer
auto gain{EU07_FMRADIO_LOUDNESS * control.volume * control.volume * control.soundproofing * Global.VehicleVolume};
auto const rangesquared{control.range * control.range};
auto const distancesquared{glm::length2(offset)};
if (distancesquared > rangesquared)
{
// outside of nominal hearing range the volume fades out, same way as it does for regular sounds
auto const fadedistance{control.range * 0.75f};
gain *= std::lerp(1.f, 0.f, std::clamp((distancesquared - rangesquared) / (fadedistance * fadedistance), 0.f, 1.f));
}
::alSourcef(m_source, AL_GAIN, gain);
::alSourcef(m_source, AL_REFERENCE_DISTANCE, control.range * (1.f / 16.f) * control.soundproofing);
// playback. the emitter stops on its own when it runs out of queued data, so it may need a restart
ALint state{AL_INITIAL};
::alGetSourcei(m_source, AL_SOURCE_STATE, &state);
if (state != AL_PLAYING && queuedcount > 0)
{
::alSourcePlay(m_source);
}
}
// stops the reception and frees implementation-side resources. expects active audio context
void fm_radio::release()
{
m_stream.close();
m_station = -1;
m_candidate = -1;
m_active = false;
if (m_source != audio::null_resource)
{
reset_emitter();
::alDeleteSources(1, &m_source);
m_source = audio::null_resource;
}
if (false == m_buffers.empty())
{
::alDeleteBuffers(static_cast<ALsizei>(m_buffers.size()), m_buffers.data());
m_buffers.clear();
m_sparebuffers.clear();
}
}
bool fm_radio::create_emitter()
{
::alGetError(); // reset the error state, so we don't get blamed for past sins of others
::alGenSources(1, &m_source);
if (::alGetError() != AL_NO_ERROR)
{
m_source = audio::null_resource;
if (false == m_emitterfailed)
{
ErrorLog("fm radio: failed to create sound emitter", logtype::sound);
m_emitterfailed = true;
}
return false;
}
if (m_buffers.empty())
{
m_buffers.resize(EU07_FMRADIO_BUFFERCOUNT);
::alGenBuffers(static_cast<ALsizei>(m_buffers.size()), m_buffers.data());
if (::alGetError() != AL_NO_ERROR)
{
m_buffers.clear();
::alDeleteSources(1, &m_source);
m_source = audio::null_resource;
if (false == m_emitterfailed)
{
ErrorLog("fm radio: failed to create sound buffers", logtype::sound);
m_emitterfailed = true;
}
return false;
}
}
m_sparebuffers = m_buffers;
m_emitterfailed = false;
::alSourcef(m_source, AL_PITCH, 1.f);
::alSourcef(m_source, AL_MAX_GAIN, EU07_FMRADIO_LOUDNESS); // by default the gain is capped at 1
::alSourcef(m_source, AL_ROLLOFF_FACTOR, 1.75f); // matches the setup of regular sounds
::alSourcei(m_source, AL_LOOPING, AL_FALSE);
glm::vec3 const zero{0.f};
::alSourcefv(m_source, AL_VELOCITY, glm::value_ptr(zero));
return true;
}
// stops the playback and takes back all sound buffers
void fm_radio::reset_emitter()
{
if (m_source == audio::null_resource)
{
return;
}
::alSourceStop(m_source);
::alSourcei(m_source, AL_BUFFER, 0); // detaches all queued buffers
m_sparebuffers = m_buffers;
}
// collects buffers which were played back. returns: number of buffers still waiting in the queue
int fm_radio::reclaim_buffers()
{
ALint processedcount{0};
::alGetSourcei(m_source, AL_BUFFERS_PROCESSED, &processedcount);
while (processedcount-- > 0)
{
ALuint buffer{0};
::alSourceUnqueueBuffers(m_source, 1, &buffer);
if (buffer != 0 && m_sparebuffers.size() < m_buffers.size())
{
m_sparebuffers.emplace_back(buffer);
}
}
return static_cast<int>(m_buffers.size() - m_sparebuffers.size());
}
} // namespace audio

141
audio/fmradio.h Normal file
View File

@@ -0,0 +1,141 @@
/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
#pragma once
#include "audio/audio.h"
#include "audio/radiostream.h"
namespace audio
{
float const EU07_FMRADIO_BANDSTART{87.5f}; // MHz
float const EU07_FMRADIO_BANDEND{108.f}; // MHz
// kind of the receiver, determines character of its sound
enum class fmradio_type
{
cheap, // pocket-sized plastic receiver with a tiny speaker
wooden // old table receiver with a large speaker in a wooden cabinet
};
// broadcast radio receiver installed in a vehicle cab.
// the stations are internet radio streams assigned to frequencies of the fm band in a text file.
// the sound is generated piece by piece from the stream and synthesized noise, then played through a positional emitter
// owned by the receiver, as this doesn't fit the regular buffer-based sound pipeline
class fm_radio
{
public:
// constructors
fm_radio() = default;
// methods
// owner-side interface, to be called by the vehicle the receiver is installed in.
// NOTE: the owner is expected to call power() on regular basis, a receiver left without supervision turns itself off
void power(bool const State);
// sets frequency the receiver is tuned to, in MHz
void tune(float const Frequency);
// sets position of the volume knob, in range 0-1
void volume(float const Volume);
void type(fmradio_type const Type);
// sets location of the receiver in the simulation region space, and audibility parameters at the current position of the listener
void placement(glm::dvec3 const &Location, float const Soundproofing, float const Range);
// returns frequency of the first station on the list, or middle of the band if there's none
float default_frequency();
// renderer-side interface
// updates state of the receiver. expects active audio context
void update(double const Deltatime, glm::dvec3 const &Listener);
// stops the reception and frees implementation-side resources. expects active audio context
void release();
private:
// types
struct control_block
{
bool power{false};
float frequency{EU07_FMRADIO_BANDSTART};
float volume{0.f};
fmradio_type type{fmradio_type::cheap};
glm::dvec3 location{0.0};
float soundproofing{1.f};
float range{15.f};
double unattended{0.0}; // time since the last call from the owner
};
struct station
{
std::string name;
std::string url;
float frequency{0.f}; // MHz
};
// second order filter section
struct biquad
{
float b0{1.f}, b1{0.f}, b2{0.f}, a1{0.f}, a2{0.f};
float z1{0.f}, z2{0.f};
float process(float const Input)
{
auto const output{b0 * Input + z1};
z1 = b1 * Input - a1 * output + z2;
z2 = b2 * Input - a2 * output;
return output;
}
static biquad highpass(float const Rate, float const Frequency, float const Q);
static biquad lowpass(float const Rate, float const Frequency, float const Q);
static biquad peak(float const Rate, float const Frequency, float const Q, float const Gain);
};
// methods
void load_stations();
// returns index of the station which can be received at specified frequency, or -1
int find_station(float const Frequency) const;
// selects the station to receive, taking into account movement of the tuning knob
void update_tuning(control_block const &Control, double const Deltatime);
// prepares speaker simulation for specified receiver
void setup_speaker(fmradio_type const Type);
// fills the staging area with next piece of the receiver output
void generate_chunk(control_block const &Control, std::size_t const Samplecount);
bool create_emitter();
// stops the playback and takes back all sound buffers
void reset_emitter();
// collects buffers which were played back. returns: number of buffers still waiting in the queue
int reclaim_buffers();
// members
std::mutex m_controlmutex;
control_block m_control; // settings requested by the owner
std::mutex m_stationsmutex;
std::vector<station> m_stations;
bool m_stationsloaded{false};
radio_stream m_stream;
bool m_active{false}; // the receiver is switched on
int m_station{-1}; // station the stream is connected with
int m_candidate{-1}; // station at the current position of the tuning knob
double m_candidatetime{0.0}; // time the knob has spent at the candidate station
double m_lingertime{0.0}; // time since the knob has left the station the stream is connected with
bool m_buffering{true}; // station audio is held until there's enough data to keep it going
float m_reception{0.f}; // current quality of the station audio, 0-1
float m_carrier{0.f}; // current strength of the station signal, 0-1
bool m_locked{false}; // the receiver is locked on a station signal
int m_thumpposition{-1}; // progress of the sound made when the receiver locks on a station or loses it, in samples. negative if the sound isn't playing
double m_inputphase{0.0}; // sample rate conversion, position between two most recent samples of station audio
std::array<std::int16_t, 2> m_inputsamples{0, 0}; // sample rate conversion, two most recent samples of station audio
fmradio_type m_speakertype{fmradio_type::cheap};
std::vector<biquad> m_speaker; // filters simulating the speaker and the case
float m_speakerdrive{1.f}; // signal level at the input of the simulated amplifier
float m_speakergain{1.f}; // make-up gain
std::uint32_t m_noisestate{0x2545f491}; // state of the noise generator
bool m_emitterfailed{false}; // helper, prevents flooding the log with the same error
ALuint m_source{audio::null_resource};
std::vector<ALuint> m_buffers; // all sound buffers owned by the receiver
std::vector<ALuint> m_sparebuffers; // sound buffers which aren't queued for playback
std::vector<std::int16_t> m_chunk; // helper, staging area for audio data
std::vector<std::int16_t> m_input; // helper, staging area for audio data
std::vector<float> m_mix; // helper, staging area for audio data
};
} // namespace audio

910
audio/radiostream.cpp Normal file
View File

@@ -0,0 +1,910 @@
/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
#include "stdafx.h"
#include "audio/radiostream.h"
#ifdef DBG_NEW
#undef new
#endif
#define ASIO_DISABLE_VISIBILITY
#include <asio.hpp>
#define DR_MP3_IMPLEMENTATION
#define DR_MP3_NO_STDIO
#include "ref/dr_libs/dr_mp3.h"
namespace audio
{
namespace
{
auto const EU07_RADIOSTREAM_BUFFERLENGTH{8}; // seconds of decoded audio kept for the owner
auto const EU07_RADIOSTREAM_CONNECTTIMEOUT{std::chrono::seconds{10}};
auto const EU07_RADIOSTREAM_STALLTIMEOUT{std::chrono::seconds{10}}; // max time without any incoming data
auto const EU07_RADIOSTREAM_MAXREDIRECTS{5};
std::size_t const EU07_RADIOSTREAM_MAXHEADERSIZE{32 * 1024};
std::size_t const EU07_RADIOSTREAM_MAXPLAYLISTSIZE{64 * 1024};
// the decoder needs to see the beginning of the next frame to stay in sync, so it's only fed when there's more data than a frame can take.
// we start with the size safe for the largest frames, then scale it down to the frames of the stream,
// otherwise audio of low bitrate streams would be delivered in pieces up to a second long
std::size_t const EU07_RADIOSTREAM_DECODETHRESHOLD{4096};
std::size_t const EU07_RADIOSTREAM_MINDECODETHRESHOLD{512};
// amount of data without a single valid frame after which the stream is deemed not to be mp3
std::size_t const EU07_RADIOSTREAM_MAXJUNKSIZE{512 * 1024};
std::string lowercase(std::string Text)
{
std::transform(std::begin(Text), std::end(Text), std::begin(Text), [](unsigned char const c) { return static_cast<char>(std::tolower(c)); });
return Text;
}
std::string trim(std::string const &Text)
{
auto const first{Text.find_first_not_of(" \t\r\n")};
if (first == std::string::npos)
{
return {};
}
auto const last{Text.find_last_not_of(" \t\r\n")};
return Text.substr(first, last - first + 1);
}
bool starts_with_nocase(std::string const &Text, std::string const &Prefix)
{
return Text.size() >= Prefix.size() && lowercase(Text.substr(0, Prefix.size())) == Prefix;
}
struct stream_address
{
std::string host;
std::string port{"80"};
std::string target{"/"};
// host with optional port, as expected by http host header
std::string authority() const
{
auto const name{host.find(':') != std::string::npos ? "[" + host + "]" : host};
return port == "80" ? name : name + ":" + port;
}
};
// splits provided address into components. returns: empty string on success, or description of the problem
std::string parse_address(std::string const &Url, stream_address &Address)
{
auto url{trim(Url)};
if (starts_with_nocase(url, "https://"))
{
return "encrypted (https) streams aren't supported";
}
if (starts_with_nocase(url, "http://"))
{
url.erase(0, 7);
}
else if (url.find("://") != std::string::npos)
{
return "unsupported protocol";
}
Address = stream_address{};
auto const hostend{url.find_first_of("/?")};
auto authority{url.substr(0, hostend)};
if (hostend != std::string::npos)
{
Address.target = url.substr(hostend);
if (Address.target.front() == '?')
{
Address.target.insert(0, "/");
}
}
// credentials aren't supported, but shouldn't prevent the connection
auto const credentialsend{authority.rfind('@')};
if (credentialsend != std::string::npos)
{
authority.erase(0, credentialsend + 1);
}
if (false == authority.empty() && authority.front() == '[')
{
// ipv6 literal
auto const literalend{authority.find(']')};
if (literalend == std::string::npos)
{
return "malformed address";
}
Address.host = authority.substr(1, literalend - 1);
if (literalend + 2 < authority.size() && authority[literalend + 1] == ':')
{
Address.port = authority.substr(literalend + 2);
}
}
else
{
auto const portstart{authority.rfind(':')};
Address.host = authority.substr(0, portstart);
if (portstart != std::string::npos && portstart + 1 < authority.size())
{
Address.port = authority.substr(portstart + 1);
}
}
if (Address.host.empty())
{
return "malformed address";
}
return {};
}
// combines address received in a redirect with the address of the redirecting resource
std::string resolve_redirect(std::string const &Location, stream_address const &Origin)
{
if (Location.find("://") != std::string::npos)
{
return Location;
}
if (Location.rfind("//", 0) == 0)
{
return "http:" + Location;
}
auto const root{"http://" + Origin.authority()};
if (false == Location.empty() && Location.front() == '/')
{
return root + Location;
}
auto directory{Origin.target.substr(0, Origin.target.find('?'))};
directory.erase(directory.rfind('/') + 1);
return root + directory + Location;
}
// extracts address of the first stream listed in provided m3u or pls playlist. returns: the address, or empty string
std::string parse_playlist(std::string const &Playlist)
{
std::istringstream lines{Playlist};
std::string line;
while (std::getline(lines, line))
{
line = trim(line);
if (line.empty() || line.front() == '#' || line.front() == '[')
{
continue;
}
// pls entries have form of FileN=address, m3u ones are plain addresses
if (starts_with_nocase(line, "file"))
{
auto const separator{line.find('=')};
if (separator != std::string::npos)
{
line = trim(line.substr(separator + 1));
}
}
if (starts_with_nocase(line, "http://") || starts_with_nocase(line, "https://"))
{
return line;
}
}
return {};
}
struct http_response
{
int status{0};
std::map<std::string, std::string> headers; // names are stored in lower case
std::string body; // part of the content which arrived together with the header
std::string header(std::string const &Name) const
{
auto const lookup{headers.find(Name)};
return lookup != std::end(headers) ? lookup->second : std::string{};
}
};
// parses provided response header. returns: true on success
bool parse_response(std::string const &Header, http_response &Response)
{
std::istringstream lines{Header};
std::string line;
if (false == static_cast<bool>(std::getline(lines, line)))
{
return false;
}
// status line, either regular 'HTTP/1.x 200 OK' or 'ICY 200 OK' sent by shoutcast servers
std::istringstream statusline{line};
std::string protocol;
statusline >> protocol >> Response.status;
if (statusline.fail() || (false == starts_with_nocase(protocol, "http/") && lowercase(protocol) != "icy"))
{
return false;
}
while (std::getline(lines, line))
{
auto const separator{line.find(':')};
if (separator == std::string::npos)
{
continue;
}
Response.headers[lowercase(trim(line.substr(0, separator)))] = trim(line.substr(separator + 1));
}
return true;
}
// removes http chunked transfer framing from received data, if there's any
class content_decoder
{
public:
explicit content_decoder(bool const Chunked) : m_chunked{Chunked} {}
// appends payload of provided data to the output. returns: false if the data is malformed
bool feed(std::uint8_t const *Data, std::size_t const Size, std::vector<std::uint8_t> &Output)
{
if (false == m_chunked)
{
Output.insert(std::end(Output), Data, Data + Size);
return true;
}
std::size_t index{0};
while (index < Size)
{
switch (m_stage)
{
case stage::size:
{
auto const character{static_cast<char>(Data[index++])};
if (character == '\n')
{
// chunk size is a hex number, optionally followed by extensions we don't care about
char *numberend{nullptr};
m_remaining = std::strtoul(m_line.c_str(), &numberend, 16);
if (numberend == m_line.c_str())
{
return false;
}
m_line.clear();
m_stage = (m_remaining > 0 ? stage::payload : stage::done);
}
else if (character != '\r')
{
m_line += character;
if (m_line.size() > 256)
{
return false;
}
}
break;
}
case stage::payload:
{
auto const count{std::min<std::size_t>(m_remaining, Size - index)};
Output.insert(std::end(Output), Data + index, Data + index + count);
index += count;
m_remaining -= count;
if (m_remaining == 0)
{
m_stage = stage::separator;
}
break;
}
case stage::separator:
{
// payload of each chunk is followed by a line break
if (Data[index++] == '\n')
{
m_stage = stage::size;
}
break;
}
case stage::done:
{
return true;
}
}
}
return true;
}
bool done() const
{
return m_stage == stage::done;
}
private:
enum class stage
{
size,
payload,
separator,
done
};
bool m_chunked;
stage m_stage{stage::size};
std::string m_line;
unsigned long m_remaining{0};
};
} // namespace
// single connection attempt of the radio stream worker
class radio_session
{
public:
enum class result
{
stopped, // the owner lost interest in the stream
failed, // connection didn't work out or was lost, it's worth to try again
unsupported // there's nothing we can do with this stream
};
radio_session(radio_stream &Owner, std::stop_token Stoptoken, std::uint32_t const Generation) : m_owner{Owner}, m_stoptoken{std::move(Stoptoken)}, m_generation{Generation}, m_resolver{m_io}, m_socket{m_io} {}
~radio_session()
{
asio::error_code ignored;
m_socket.close(ignored);
}
// connects with specified stream and feeds the owner with decoded audio until the connection ends
result play(std::string Url);
// returns true if the session delivered any audio
bool delivered() const
{
return m_delivered;
}
private:
// runs pending network operation until specified flag is set by its completion handler. returns: false if the operation was aborted
bool wait(bool const &Done, std::chrono::seconds const Timeout);
bool connect(stream_address const &Address);
bool send(std::string const &Data);
// reads some data from the socket. returns: number of received bytes, or 0 if the connection is done for
std::size_t receive(std::uint8_t *Data, std::size_t const Size);
bool receive_response(http_response &Response);
// retrieves the playlist and replaces provided address with the one found in it. returns: false if it wasn't possible
bool receive_playlist(http_response const &Response, std::string &Url, result &Failure);
result receive_audio(http_response const &Response);
result fail(std::string const &Reason, result const Result = result::failed);
radio_stream &m_owner;
std::stop_token m_stoptoken;
std::uint32_t m_generation;
asio::io_context m_io;
asio::ip::tcp::resolver m_resolver;
asio::ip::tcp::socket m_socket;
std::string m_error; // description of the most recent network problem
bool m_delivered{false};
};
bool radio_session::wait(bool const &Done, std::chrono::seconds const Timeout)
{
auto const deadline{std::chrono::steady_clock::now() + Timeout};
while (false == Done)
{
auto const timedout{std::chrono::steady_clock::now() >= deadline};
if (timedout || m_stoptoken.stop_requested())
{
m_error = (timedout ? "operation timed out" : "");
// abort the operation, then let its completion handler run as it refers to data of our caller
asio::error_code ignored;
m_resolver.cancel();
m_socket.close(ignored);
m_io.restart();
m_io.run();
return false;
}
if (m_io.stopped())
{
m_io.restart();
}
m_io.run_one_for(std::chrono::milliseconds{100});
}
return true;
}
bool radio_session::connect(stream_address const &Address)
{
asio::error_code error;
asio::ip::tcp::resolver::results_type endpoints;
bool done{false};
m_resolver.async_resolve(Address.host, Address.port, [&](asio::error_code const &Error, asio::ip::tcp::resolver::results_type Results) {
error = Error;
endpoints = std::move(Results);
done = true;
});
if (false == wait(done, EU07_RADIOSTREAM_CONNECTTIMEOUT))
{
return false;
}
if (error)
{
m_error = "can't find the server (" + error.message() + ")";
return false;
}
done = false;
asio::async_connect(m_socket, endpoints, [&](asio::error_code const &Error, asio::ip::tcp::endpoint const &) {
error = Error;
done = true;
});
if (false == wait(done, EU07_RADIOSTREAM_CONNECTTIMEOUT))
{
return false;
}
if (error)
{
m_error = "can't connect with the server (" + error.message() + ")";
return false;
}
return true;
}
bool radio_session::send(std::string const &Data)
{
asio::error_code error;
bool done{false};
asio::async_write(m_socket, asio::buffer(Data), [&](asio::error_code const &Error, std::size_t) {
error = Error;
done = true;
});
if (false == wait(done, EU07_RADIOSTREAM_CONNECTTIMEOUT))
{
return false;
}
if (error)
{
m_error = error.message();
return false;
}
return true;
}
std::size_t radio_session::receive(std::uint8_t *Data, std::size_t const Size)
{
asio::error_code error;
std::size_t received{0};
bool done{false};
m_socket.async_read_some(asio::buffer(Data, Size), [&](asio::error_code const &Error, std::size_t const Count) {
error = Error;
received = Count;
done = true;
});
if (false == wait(done, EU07_RADIOSTREAM_STALLTIMEOUT))
{
return 0;
}
if (error && received == 0)
{
m_error = (error == asio::error::eof ? "connection closed by the server" : error.message());
}
return received;
}
bool radio_session::receive_response(http_response &Response)
{
std::string data;
std::array<std::uint8_t, 2048> chunk;
while (true)
{
// some servers end lines with a bare line feed, so we look for both variants
auto headerend{data.find("\r\n\r\n")};
auto headerendsize{std::size_t{4}};
if (headerend == std::string::npos)
{
headerend = data.find("\n\n");
headerendsize = 2;
}
if (headerend != std::string::npos)
{
Response = http_response{};
if (false == parse_response(data.substr(0, headerend), Response))
{
m_error = "unrecognized reply from the server";
return false;
}
Response.body = data.substr(headerend + headerendsize);
return true;
}
if (data.size() > EU07_RADIOSTREAM_MAXHEADERSIZE)
{
m_error = "unrecognized reply from the server";
return false;
}
auto const received{receive(chunk.data(), chunk.size())};
if (received == 0)
{
return false;
}
data.append(reinterpret_cast<char const *>(chunk.data()), received);
}
}
radio_session::result radio_session::fail(std::string const &Reason, result const Result)
{
if (m_stoptoken.stop_requested())
{
return result::stopped;
}
if (false == Reason.empty())
{
m_owner.post("radio stream: " + Reason, true);
}
return Result;
}
radio_session::result radio_session::play(std::string Url)
{
for (auto hop{0}; hop <= EU07_RADIOSTREAM_MAXREDIRECTS; ++hop)
{
stream_address address;
auto const addresserror{parse_address(Url, address)};
if (false == addresserror.empty())
{
return fail(addresserror + " \"" + Url + "\"", result::unsupported);
}
m_owner.status(radio_stream::state::connecting, m_generation);
m_owner.post("radio stream: connecting with \"" + Url + "\"");
{
// each hop needs a fresh connection
asio::error_code ignored;
m_socket.close(ignored);
}
if (false == connect(address))
{
return fail(m_error);
}
// NOTE: we don't ask for the in-band metadata, so the content is pure audio
auto const request{"GET " + address.target + " HTTP/1.1\r\nHost: " + address.authority() + "\r\nUser-Agent: MaSzyna\r\nAccept: */*\r\nConnection: close\r\n\r\n"};
http_response response;
if (false == send(request) || false == receive_response(response))
{
return fail(m_error);
}
if (response.status >= 300 && response.status < 400)
{
auto const location{response.header("location")};
if (location.empty())
{
return fail("server sent a redirect without the target");
}
Url = resolve_redirect(location, address);
continue;
}
if (response.status != 200)
{
return fail("server replied with status " + std::to_string(response.status));
}
auto const contenttype{lowercase(response.header("content-type"))};
auto const path{lowercase(address.target.substr(0, address.target.find('?')))};
auto const isplaylist{contenttype.find("mpegurl") != std::string::npos || contenttype.find("scpls") != std::string::npos || path.ends_with(".m3u") || path.ends_with(".pls")};
if (isplaylist)
{
auto failure{result::failed};
if (false == receive_playlist(response, Url, failure))
{
return failure;
}
continue;
}
for (auto const *format : {"aac", "ogg", "opus", "flac"})
{
if (contenttype.find(format) != std::string::npos)
{
return fail("unsupported audio format \"" + contenttype + "\", only mp3 streams can be played", result::unsupported);
}
}
auto const name{response.header("icy-name")};
auto const bitrate{response.header("icy-br")};
m_owner.post("radio stream: connected" + (name.empty() ? "" : ", station \"" + name + "\"") + (bitrate.empty() ? "" : ", " + bitrate + " kbps"));
return receive_audio(response);
}
return fail("too many redirects");
}
bool radio_session::receive_playlist(http_response const &Response, std::string &Url, result &Failure)
{
content_decoder content{lowercase(Response.header("transfer-encoding")).find("chunked") != std::string::npos};
std::vector<std::uint8_t> playlist;
if (false == content.feed(reinterpret_cast<std::uint8_t const *>(Response.body.data()), Response.body.size(), playlist))
{
Failure = fail("malformed reply from the server");
return false;
}
std::array<std::uint8_t, 2048> chunk;
while (false == content.done() && playlist.size() < EU07_RADIOSTREAM_MAXPLAYLISTSIZE)
{
// the playlist is complete when the server closes the connection
auto const received{receive(chunk.data(), chunk.size())};
if (received == 0)
{
break;
}
if (false == content.feed(chunk.data(), received, playlist))
{
Failure = fail("malformed reply from the server");
return false;
}
}
if (m_stoptoken.stop_requested())
{
Failure = result::stopped;
return false;
}
auto const address{parse_playlist(std::string{std::begin(playlist), std::end(playlist)})};
if (address.empty())
{
Failure = fail("playlist \"" + Url + "\" doesn't contain any stream address", result::unsupported);
return false;
}
Url = address;
return true;
}
radio_session::result radio_session::receive_audio(http_response const &Response)
{
content_decoder content{lowercase(Response.header("transfer-encoding")).find("chunked") != std::string::npos};
std::vector<std::uint8_t> input;
input.reserve(4 * EU07_RADIOSTREAM_DECODETHRESHOLD);
if (false == content.feed(reinterpret_cast<std::uint8_t const *>(Response.body.data()), Response.body.size(), input))
{
return fail("malformed reply from the server");
}
auto decoder{std::make_unique<drmp3dec>()};
::drmp3dec_init(decoder.get());
std::vector<std::int16_t> frame(DRMP3_MAX_SAMPLES_PER_FRAME);
std::array<std::uint8_t, 4096> chunk;
std::size_t junksize{0};
auto threshold{EU07_RADIOSTREAM_DECODETHRESHOLD};
m_owner.status(radio_stream::state::receiving, m_generation);
while (true)
{
// decode what we have...
std::size_t position{0};
while (input.size() - position >= threshold)
{
drmp3dec_frame_info info{};
auto const samplecount{::drmp3dec_decode_frame(decoder.get(), input.data() + position, static_cast<int>(input.size() - position), frame.data(), &info)};
if (samplecount > 0)
{
if (info.channels > 1)
{
// mix the channels down, the sound is going to be emitted from a single point in the cab
for (auto index{0}; index < samplecount; ++index)
{
auto sum{0};
for (auto channel{0}; channel < info.channels; ++channel)
{
sum += frame[index * info.channels + channel];
}
frame[index] = static_cast<std::int16_t>(sum / info.channels);
}
}
m_owner.store(frame.data(), static_cast<std::size_t>(samplecount), static_cast<unsigned int>(info.sample_rate), m_generation);
m_delivered = true;
junksize = 0;
threshold = std::clamp(static_cast<std::size_t>(info.frame_bytes) * 3, EU07_RADIOSTREAM_MINDECODETHRESHOLD, EU07_RADIOSTREAM_DECODETHRESHOLD);
}
else if (info.frame_bytes > 0)
{
// data which isn't part of any frame. the decoder is looking for the stream again, which takes more data
junksize += static_cast<std::size_t>(info.frame_bytes);
threshold = EU07_RADIOSTREAM_DECODETHRESHOLD;
}
else
{
// the decoder wants more data
break;
}
position += static_cast<std::size_t>(info.frame_bytes);
}
input.erase(std::begin(input), std::begin(input) + static_cast<std::ptrdiff_t>(position));
if (junksize > EU07_RADIOSTREAM_MAXJUNKSIZE || input.size() > EU07_RADIOSTREAM_MAXJUNKSIZE)
{
return fail(m_delivered ? "lost track of audio data in the stream" : "no mp3 audio found in the stream, only mp3 streams can be played", m_delivered ? result::failed : result::unsupported);
}
// ...then get some more
if (content.done())
{
return fail("stream ended");
}
auto const received{receive(chunk.data(), chunk.size())};
if (received == 0)
{
return fail(m_error);
}
if (false == content.feed(chunk.data(), received, input))
{
return fail("malformed data from the server");
}
}
}
radio_stream::~radio_stream()
{
close();
// NOTE: destructors of retired workers wait for their threads to finish
}
// starts reception of specified stream, dropping current one if there's any
void radio_stream::open(std::string const &Url)
{
close();
if (Url.empty())
{
return;
}
m_url = Url;
std::uint32_t generation;
{
std::scoped_lock lock{m_mutex};
generation = m_generation;
m_state = state::connecting;
}
m_worker.finished = std::make_shared<std::atomic<bool>>(false);
m_worker.thread = std::jthread{[this](std::stop_token Stoptoken, std::string Address, std::uint32_t const Workergeneration, std::shared_ptr<std::atomic<bool>> Finished) { run(std::move(Stoptoken), std::move(Address), Workergeneration, std::move(Finished)); },
Url, generation, m_worker.finished};
}
// terminates reception of the current stream
void radio_stream::close()
{
{
std::scoped_lock lock{m_mutex};
// from now on the current worker can't deliver anything
++m_generation;
m_samplesstart = 0;
m_samplescount = 0;
m_state = state::idle;
m_rate = 0;
}
m_url.clear();
if (m_worker.thread.joinable())
{
// the worker may need a moment to shut down its connection. we don't wait for it here, as we're likely called from the main thread
m_worker.thread.request_stop();
m_retired.emplace_back(std::move(m_worker));
m_worker = worker{};
}
reap();
}
// disposes of retired workers which are done with their cleanup
void radio_stream::reap()
{
std::erase_if(m_retired, [](worker const &Worker) { return Worker.finished->load(); });
}
// returns number of decoded samples waiting to be collected
std::size_t radio_stream::available() const
{
std::scoped_lock lock{m_mutex};
return m_samplescount;
}
// moves up to specified number of decoded samples to provided buffer. returns: number of samples moved
std::size_t radio_stream::read(std::int16_t *Output, std::size_t const Count)
{
std::scoped_lock lock{m_mutex};
auto const count{std::min(Count, m_samplescount)};
for (std::size_t index{0}; index < count; ++index)
{
Output[index] = m_samples[(m_samplesstart + index) % m_samples.size()];
}
if (count > 0)
{
m_samplesstart = (m_samplesstart + count) % m_samples.size();
m_samplescount -= count;
}
return count;
}
// retrieves next diagnostic message posted by the worker. returns: false if there's no message waiting
bool radio_stream::fetch_message(message &Message)
{
std::scoped_lock lock{m_messagesmutex};
if (m_messages.empty())
{
return false;
}
Message = std::move(m_messages.front());
m_messages.pop_front();
return true;
}
void radio_stream::run(std::stop_token Stoptoken, std::string Url, std::uint32_t const Generation, std::shared_ptr<std::atomic<bool>> Finished)
{
auto failures{0};
while (false == Stoptoken.stop_requested())
{
auto result{radio_session::result::failed};
auto delivered{false};
{
radio_session session{*this, Stoptoken, Generation};
result = session.play(Url);
delivered = session.delivered();
}
if (result == radio_session::result::stopped || Stoptoken.stop_requested())
{
break;
}
if (result == radio_session::result::unsupported)
{
status(state::failed, Generation);
break;
}
// try again after a pause, which gets longer if the server keeps refusing to cooperate
failures = (delivered ? 0 : failures + 1);
auto const pause{std::chrono::seconds{std::min(15, 1 << std::min(failures, 4))}};
status(state::waiting, Generation);
post("radio stream: next connection attempt in " + std::to_string(pause.count()) + " s");
auto const deadline{std::chrono::steady_clock::now() + pause};
while (false == Stoptoken.stop_requested() && std::chrono::steady_clock::now() < deadline)
{
std::this_thread::sleep_for(std::chrono::milliseconds{50});
}
}
Finished->store(true);
}
// stores decoded samples, discarding oldest data if there's no room left. data from outdated workers is ignored
void radio_stream::store(std::int16_t const *Samples, std::size_t const Count, unsigned int const Rate, std::uint32_t const Generation)
{
std::scoped_lock lock{m_mutex};
if (Generation != m_generation || Rate == 0)
{
return;
}
if (Rate != m_rate)
{
// (re)initialize the buffer for the new format
m_samples.assign(static_cast<std::size_t>(Rate) * EU07_RADIOSTREAM_BUFFERLENGTH, 0);
m_samplesstart = 0;
m_samplescount = 0;
m_rate = Rate;
}
auto const capacity{m_samples.size()};
for (std::size_t index{0}; index < Count; ++index)
{
m_samples[(m_samplesstart + m_samplescount) % capacity] = Samples[index];
if (m_samplescount < capacity)
{
++m_samplescount;
}
else
{
// the buffer is full, the oldest sample was just overwritten
m_samplesstart = (m_samplesstart + 1) % capacity;
}
}
}
void radio_stream::status(state const State, std::uint32_t const Generation)
{
std::scoped_lock lock{m_mutex};
if (Generation == m_generation)
{
m_state = State;
}
}
void radio_stream::post(std::string Text, bool const Error)
{
std::scoped_lock lock{m_messagesmutex};
m_messages.emplace_back(std::move(Text), Error);
// if nobody collects the messages there's no point in hoarding them
while (m_messages.size() > 64)
{
m_messages.pop_front();
}
}
} // namespace audio

108
audio/radiostream.h Normal file
View File

@@ -0,0 +1,108 @@
/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace audio
{
// receiver of an internet radio stream (plain http, mp3 payload).
// owns a worker thread which maintains the connection, decodes received data
// and stores the result as 16-bit mono samples for the owner to collect.
// the worker doesn't touch anything outside of the object, and the owner is never made to wait for the network
class radio_stream
{
friend class radio_session;
public:
// types
enum class state
{
idle, // no stream requested
connecting, // establishing connection with the server
receiving, // audio data is coming in
waiting, // connection was lost or refused, waiting to make another attempt
failed // stream can't be played, no further attempts will be made
};
using message = std::pair<std::string, bool>; // text, is error
// constructors
radio_stream() = default;
radio_stream(radio_stream const &) = delete;
radio_stream &operator=(radio_stream const &) = delete;
// destructor
~radio_stream();
// methods
// starts reception of specified stream, dropping current one if there's any
void open(std::string const &Url);
// terminates reception of the current stream
void close();
// returns address of currently requested stream, or empty string
std::string const &url() const
{
return m_url;
}
state status() const
{
return m_state.load();
}
// returns number of decoded samples waiting to be collected
std::size_t available() const;
// returns sample rate of decoded audio, or 0 if nothing was decoded yet
unsigned int rate() const
{
return m_rate.load();
}
// moves up to specified number of decoded samples to provided buffer. returns: number of samples moved
std::size_t read(std::int16_t *Output, std::size_t const Count);
// retrieves next diagnostic message posted by the worker. returns: false if there's no message waiting
bool fetch_message(message &Message);
private:
// types
struct worker
{
std::jthread thread;
std::shared_ptr<std::atomic<bool>> finished;
};
// methods
void run(std::stop_token Stoptoken, std::string Url, std::uint32_t const Generation, std::shared_ptr<std::atomic<bool>> Finished);
// stores decoded samples, discarding oldest data if there's no room left. data from outdated workers is ignored
void store(std::int16_t const *Samples, std::size_t const Count, unsigned int const Rate, std::uint32_t const Generation);
void status(state const State, std::uint32_t const Generation);
void post(std::string Text, bool const Error = false);
// disposes of retired workers which are done with their cleanup
void reap();
// members
std::string m_url;
worker m_worker;
std::vector<worker> m_retired; // workers which were told to stop but may still be busy closing their connection
std::atomic<state> m_state{state::idle};
std::atomic<unsigned int> m_rate{0};
mutable std::mutex m_mutex; // guards the sample buffer and the generation counter
std::uint32_t m_generation{0}; // identifies the worker allowed to deliver data
std::vector<std::int16_t> m_samples; // ring buffer
std::size_t m_samplesstart{0};
std::size_t m_samplescount{0};
std::mutex m_messagesmutex;
std::deque<message> m_messages;
};
} // namespace audio

View File

@@ -914,6 +914,18 @@ sound_source::location() const {
+ m_owner->VectorFront() * (double)m_offset.z };
}
// returns location-based gain factor of the emitter for the current position of the listener
float
sound_source::soundproofing_factor() {
// NOTE: the calculation needs the listener to be assigned to a vehicle or in one of the outside views.
// this may not be the case yet when we're called from vehicle update, as the global camera data is refreshed later in the frame
if( FreeFlyModeFlag || Global.CabWindowOpen || Global.pCamera.m_owner != nullptr ) {
update_soundproofing();
}
return m_properties.soundproofing;
}
void
sound_source::range( float const Range ) {

View File

@@ -139,6 +139,9 @@ public:
// returns location of the sound source in simulation region space
glm::dvec3
location() const;
// returns location-based gain factor of the emitter for the current position of the listener
float
soundproofing_factor();
// returns defined range of the sound
void
range( float const Range );

View File

@@ -405,6 +405,13 @@ commanddescription_sequence Commands_descriptions = {
{"wiperswitchincrease", command_target::vehicle, command_mode::oneoff},
{"wiperswitchdecrease", command_target::vehicle, command_mode::oneoff},
{"lightsset", command_target::vehicle, command_mode::oneoff},
{"fmradiotoggle", command_target::vehicle, command_mode::oneoff},
{"fmradiovolumeincrease", command_target::vehicle, command_mode::oneoff},
{"fmradiovolumedecrease", command_target::vehicle, command_mode::oneoff},
{"fmradiovolumeset", command_target::vehicle, command_mode::oneoff},
{"fmradiotuneincrease", command_target::vehicle, command_mode::oneoff},
{"fmradiotunedecrease", command_target::vehicle, command_mode::oneoff},
{"fmradiotuneset", command_target::vehicle, command_mode::oneoff},
};
// Maps of command and coresponding strings
@@ -789,6 +796,13 @@ std::unordered_map<std::string, user_command> const commandMap = {
{"wiperswitchincrease", user_command::wiperswitchincrease},
{"wiperswitchdecrease", user_command::wiperswitchdecrease},
{"lightsset", user_command::lightsset},
{"fmradiotoggle", user_command::fmradiotoggle},
{"fmradiovolumeincrease", user_command::fmradiovolumeincrease},
{"fmradiovolumedecrease", user_command::fmradiovolumedecrease},
{"fmradiovolumeset", user_command::fmradiovolumeset},
{"fmradiotuneincrease", user_command::fmradiotuneincrease},
{"fmradiotunedecrease", user_command::fmradiotunedecrease},
{"fmradiotuneset", user_command::fmradiotuneset},
{"none", user_command::none}};
} // namespace simulation

View File

@@ -403,6 +403,13 @@ enum class user_command
wiperswitchincrease,
wiperswitchdecrease,
lightsset,
fmradiotoggle,
fmradiovolumeincrease,
fmradiovolumedecrease,
fmradiovolumeset,
fmradiotuneincrease,
fmradiotunedecrease,
fmradiotuneset,
none = -1
};

View File

@@ -816,6 +816,15 @@ drivermouse_input::default_bindings() {
{ "radiovolumenext_sw:",{
radiovolumeincrease,
none } },
{ "fmradio_sw:",{
fmradiotoggle,
none } },
{ "fmradiovolume_sw:",{
fmradiovolumeincrease,
fmradiovolumedecrease } },
{ "fmradiotune_sw:",{
fmradiotuneincrease,
fmradiotunedecrease } },
{ "pantfront_sw:", {
pantographtogglefront,
none } },

5430
ref/dr_libs/dr_mp3.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -193,6 +193,24 @@ bool global_settings::ConfigParseAudio(cParser& Parser, const std::string& token
return true;
}
if (token == "fmradio.enabled")
{
ParseOne(Parser, fmradio_enabled);
return true;
}
if (token == "fmradio.cleansound")
{
ParseOne(Parser, fmradio_cleansound);
return true;
}
if (token == "fmradio.stations")
{
fmradio_stations = Parser.getToken<std::string>(false);
return true;
}
if (token == "sound.volume.vehicle")
{
ParseOneClamped(Parser, VehicleVolume, 0.f, 1.f);
@@ -1555,6 +1573,9 @@ global_settings::export_as_text( std::ostream &Output ) const {
export_as_text( Output, "sound.volume.positional", EnvironmentPositionalVolume );
export_as_text( Output, "sound.volume.ambient", EnvironmentAmbientVolume );
export_as_text( Output, "sound.asyncload", AudioAsyncLoad );
export_as_text( Output, "fmradio.enabled", fmradio_enabled );
export_as_text( Output, "fmradio.cleansound", fmradio_cleansound );
export_as_text( Output, "fmradio.stations", fmradio_stations );
export_as_text( Output, "physicslog", WriteLogFlag );
export_as_text( Output, "fullphysics", FullPhysics );
export_as_text( Output, "debuglog", iWriteLogEnabled );

View File

@@ -201,6 +201,10 @@ struct global_settings {
int audio_max_sources = 30;
std::string AudioRenderer;
bool AudioAsyncLoad{ false }; // sound files are decoded by worker threads
// broadcast radio receiver
bool fmradio_enabled{ true }; // allows cab radio receivers to play internet radio streams
bool fmradio_cleansound{ false }; // disables simulation of the receiver speaker
std::string fmradio_stations{ "fmradio.txt" }; // list of stations available to the receivers
// input
float fMouseXScale{ 1.5f };
float fMouseYScale{ 0.2f };

View File

@@ -483,6 +483,13 @@ TTrain::commandhandler_map const TTrain::m_commandhandlers = {
{user_command::radiovolumeincrease, &TTrain::OnCommand_radiovolumeincrease},
{user_command::radiovolumedecrease, &TTrain::OnCommand_radiovolumedecrease},
{user_command::radiovolumeset, &TTrain::OnCommand_radiovolumeset},
{user_command::fmradiotoggle, &TTrain::OnCommand_fmradiotoggle},
{user_command::fmradiovolumeincrease, &TTrain::OnCommand_fmradiovolumeincrease},
{user_command::fmradiovolumedecrease, &TTrain::OnCommand_fmradiovolumedecrease},
{user_command::fmradiovolumeset, &TTrain::OnCommand_fmradiovolumeset},
{user_command::fmradiotuneincrease, &TTrain::OnCommand_fmradiotuneincrease},
{user_command::fmradiotunedecrease, &TTrain::OnCommand_fmradiotunedecrease},
{user_command::fmradiotuneset, &TTrain::OnCommand_fmradiotuneset},
{user_command::cabchangeforward, &TTrain::OnCommand_cabchangeforward},
{user_command::cabchangebackward, &TTrain::OnCommand_cabchangebackward},
{user_command::generictoggle0, &TTrain::OnCommand_generictoggle},
@@ -8186,6 +8193,58 @@ void TTrain::OnCommand_radiovolumeset(TTrain *Train, command_data const &Command
}
}
void TTrain::OnCommand_fmradiotoggle(TTrain *Train, command_data const &Command)
{
if (Command.action == GLFW_PRESS && Train->m_fmradiopresent)
{
Train->m_fmradiopower = !Train->m_fmradiopower;
}
}
void TTrain::OnCommand_fmradiovolumeincrease(TTrain *Train, command_data const &Command)
{
// the knob keeps turning for as long as the control is held, the actual movement takes place in the update
Train->m_fmradiovolumechange = (Command.action != GLFW_RELEASE && Train->m_fmradiopresent ? 1 : 0);
}
void TTrain::OnCommand_fmradiovolumedecrease(TTrain *Train, command_data const &Command)
{
Train->m_fmradiovolumechange = (Command.action != GLFW_RELEASE && Train->m_fmradiopresent ? -1 : 0);
}
void TTrain::OnCommand_fmradiovolumeset(TTrain *Train, command_data const &Command)
{
if (Command.action != GLFW_RELEASE && Train->m_fmradiopresent)
{
Train->m_fmradiovolume = std::clamp(static_cast<float>(Command.param1), 0.f, 1.f);
if (false == Train->m_controlmapper.contains("fmradio_sw:"))
{
// receivers without dedicated switch are switched with the volume knob
Train->m_fmradiopower = Train->m_fmradiovolume > 0.f;
}
}
}
void TTrain::OnCommand_fmradiotuneincrease(TTrain *Train, command_data const &Command)
{
Train->m_fmradiotunechange = (Command.action != GLFW_RELEASE && Train->m_fmradiopresent ? 1 : 0);
}
void TTrain::OnCommand_fmradiotunedecrease(TTrain *Train, command_data const &Command)
{
Train->m_fmradiotunechange = (Command.action != GLFW_RELEASE && Train->m_fmradiopresent ? -1 : 0);
}
// sets the receiver to frequency specified in MHz
void TTrain::OnCommand_fmradiotuneset(TTrain *Train, command_data const &Command)
{
if (Command.action != GLFW_RELEASE && Train->m_fmradiopresent)
{
Train->m_fmradiodial = std::clamp(100.f * (static_cast<float>(Command.param1) - audio::EU07_FMRADIO_BANDSTART) / (audio::EU07_FMRADIO_BANDEND - audio::EU07_FMRADIO_BANDSTART), 0.f, 100.f);
Train->m_fmradiotuned = true;
}
}
void TTrain::OnCommand_cabchangeforward(TTrain *Train, command_data const &Command)
{
@@ -10040,6 +10099,7 @@ void TTrain::update_sounds(double const Deltatime)
}
update_sounds_radio();
update_sounds_fmradio(Deltatime);
}
void TTrain::update_sounds_resonancenoise(sound_source &Sound) const
@@ -10132,6 +10192,52 @@ void TTrain::update_sounds_radio()
}
}
// operates the broadcast radio receiver
void TTrain::update_sounds_fmradio(double const Deltatime)
{
// NOTE: there's a single receiver in the simulation, it plays in the cab occupied by the user
if (simulation::Train != this || false == m_fmradiopresent || false == Global.fmradio_enabled)
{
return;
}
auto &fmradio{audio::renderer.fmradio()};
auto const bandwidth{audio::EU07_FMRADIO_BANDEND - audio::EU07_FMRADIO_BANDSTART};
if (false == m_fmradiotuned)
{
// initially the receiver is tuned to the first station on the list
m_fmradiodial = 100.f * (fmradio.default_frequency() - audio::EU07_FMRADIO_BANDSTART) / bandwidth;
m_fmradiotuned = true;
}
// knobs
if (m_fmradiovolumechange != 0)
{
m_fmradiovolume = std::clamp(m_fmradiovolume + m_fmradiovolumechange * 0.5f * static_cast<float>(Deltatime), 0.f, 1.f);
if (false == m_controlmapper.contains("fmradio_sw:"))
{
// receivers without dedicated switch are switched with the volume knob
m_fmradiopower = m_fmradiovolume > 0.f;
}
}
if (m_fmradiotunechange != 0)
{
// the knob moves slowly at first to allow fine tuning, then speeds up
m_fmradiotunetime += static_cast<float>(Deltatime);
auto const tunerate{std::lerp(0.15f, 2.5f, std::clamp(m_fmradiotunetime * 0.5f, 0.f, 1.f))}; // MHz per second
m_fmradiodial = std::clamp(m_fmradiodial + 100.f * m_fmradiotunechange * tunerate * static_cast<float>(Deltatime) / bandwidth, 0.f, 100.f);
}
else
{
m_fmradiotunetime = 0.f;
}
fmradio.type(m_fmradiotype);
fmradio.tune(audio::EU07_FMRADIO_BANDSTART + m_fmradiodial * 0.01f * bandwidth);
fmradio.volume(m_fmradiovolume);
fmradio.placement(m_fmradiosound.location(), m_fmradiosound.soundproofing_factor(), m_fmradiosound.range());
// NOTE: the receiver runs on batteries of its own, so it doesn't care about the state of the vehicle
fmradio.power(m_fmradiopower);
}
void TTrain::update_screens(double dt)
{
for (auto &screen : m_screens)
@@ -10234,6 +10340,7 @@ bool TTrain::LoadMMediaFile(std::string const &asFileName)
}
// NOTE: since radiosound is an incomplete template not using std::optional it gets a special treatment
m_radiosound.owner(DynamicObject);
m_fmradiosound.owner(DynamicObject);
CabSoundLocations.clear();
cParser parser(asFileName, cParser::buffer_FILE, DynamicObject->asBaseDir, true, std::vector<std::string>(), true);
@@ -10342,6 +10449,8 @@ bool TTrain::InitializeCab(int NewCabNo, std::string const &asFileName)
}
}
m_radiosound.offset(nullvector);
m_fmradiosound.offset(nullvector);
m_fmradiopresent = false;
for (auto const &[soundsource, soundoffset] : CabSoundLocations)
{
if (soundsource.get() && soundsource.get()->offset() == nullvector)
@@ -10567,6 +10676,39 @@ bool TTrain::InitializeCab(int NewCabNo, std::string const &asFileName)
parser.getTokens();
parser >> ScreenUpdateRate;
}
else if (token == "fmradio:")
{
// broadcast radio receiver, the parameter selects character of its sound
std::string fmradiotype;
parser.getTokens();
parser >> fmradiotype;
m_fmradiotype = (fmradiotype == "wooden" ? audio::fmradio_type::wooden : audio::fmradio_type::cheap);
m_fmradiopresent = true;
}
else if (token == "fmradiospeaker:")
{
// submodel marking location of the receiver speaker, the sound is emitted from there
std::string speakername;
parser.getTokens();
parser >> speakername;
TSubModel *speaker{nullptr};
for (auto const *source : {DynamicObject->mdKabina, DynamicObject->mdLowPolyInt})
{
if (source != nullptr && (speaker = source->GetFromName(speakername)) != nullptr)
{
break;
}
}
if (speaker != nullptr)
{
m_fmradiosound.offset(speaker->offset(1.f));
}
else
{
ErrorLog("Bad config: failed to locate fm radio speaker submodel \"" + speakername + "\"", logtype::model);
}
m_fmradiopresent = true;
}
// btLampkaUnknown`"unknown",mdKabina,false);
} while (token != ""
// TODO: enable full per-cab deserialization when/if .mmd files get proper per-cab switch configuration
@@ -10608,6 +10750,15 @@ bool TTrain::InitializeCab(int NewCabNo, std::string const &asFileName)
{
m_radiosound.offset(caboffset);
}
if (m_fmradiosound.offset() == nullvector)
{
m_fmradiosound.offset(caboffset);
}
if (m_fmradiopresent && false == m_fmradiopower && m_controlmapper.contains("fmradiovolume_sw:") && false == m_controlmapper.contains("fmradio_sw:"))
{
// receivers without dedicated switch are switched with the volume knob, so it has to start at the off position
m_fmradiovolume = 0.f;
}
std::vector<std::pair<std::reference_wrapper<std::optional<sound_source>>, glm::vec3>> soundlocations = {
{dsbReverserKey, ggDirKey.model_offset()},
{dsbNastawnikJazdy, ggJointCtrl.model_offset()},
@@ -11947,6 +12098,33 @@ bool TTrain::initialize_gauge(cParser &Parser, std::string const &Label, int con
}
}
// broadcast radio receiver
if (Label == "fmradio_sw:" || Label == "fmradiovolume_sw:" || Label == "fmradiotune_sw:" || Label == "fmradioscale:")
{
auto &gauge = Cabine[Cabindex].Gauge(-1); // pierwsza wolna gałka
gauge.Load(Parser, DynamicObject);
if (Label == "fmradio_sw:")
{
gauge.AssignBool(&m_fmradiopower);
}
else
{
gauge.AssignFloat(Label == "fmradiovolume_sw:" ? &m_fmradiovolume : &m_fmradiodial);
}
if (Label != "fmradioscale:")
{
// the scale pointer is an indicator, the rest are controls
m_controlmapper.insert(gauge, Label);
}
m_fmradiopresent = true;
// unless the speaker location is defined explicitly, the sound comes from the first defined element of the receiver
if (m_fmradiosound.offset() == glm::vec3())
{
m_fmradiosound.offset(gauge.model_offset());
}
return true;
}
// ABu 090305: uniwersalne przyciski lub inne rzeczy
if (Label == "mainctrlact:")
{

View File

@@ -216,6 +216,7 @@ class TTrain {
void update_sounds_runningnoise( sound_source &Sound );
void update_sounds_resonancenoise( sound_source &Sound ) const;
void update_sounds_radio();
void update_sounds_fmradio( double const Deltatime );
inline
end cab_to_end( int const End ) const {
return End == 2 ? end::rear : end::front; }
@@ -485,6 +486,13 @@ class TTrain {
static void OnCommand_radiovolumeincrease(TTrain *Train, command_data const &Command);
static void OnCommand_radiovolumedecrease(TTrain *Train, command_data const &Command);
static void OnCommand_radiovolumeset(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiotoggle(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiovolumeincrease(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiovolumedecrease(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiovolumeset(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiotuneincrease(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiotunedecrease(TTrain *Train, command_data const &Command);
static void OnCommand_fmradiotuneset(TTrain *Train, command_data const &Command);
static void OnCommand_cabchangeforward( TTrain *Train, command_data const &Command );
static void OnCommand_cabchangebackward( TTrain *Train, command_data const &Command );
static void OnCommand_generictoggle( TTrain *Train, command_data const &Command );
@@ -919,6 +927,17 @@ private:
bool m_couplingdisconnect { false };
bool m_couplingdisconnectback { false };
float m_radiovolume { Global.DefaultRadioVolume };
// broadcast radio receiver
bool m_fmradiopresent { false }; // the cab is equipped with the receiver
audio::fmradio_type m_fmradiotype { audio::fmradio_type::cheap };
bool m_fmradiopower { false };
bool m_fmradiotuned { false }; // helper, the tuning knob was set to its initial position
float m_fmradiovolume { 0.5f }; // position of the volume knob, in range 0-1
float m_fmradiodial { 50.f }; // position of the tuning knob, in range 0-100 covering the whole band. NOTE: gauges ignore changes of bound value smaller than 0.01, hence the wide range
int m_fmradiovolumechange { 0 }; // direction in which the volume knob is being turned
int m_fmradiotunechange { 0 }; // direction in which the tuning knob is being turned
float m_fmradiotunetime { 0.f }; // how long the tuning knob is being turned, it gets faster with time
sound_source m_fmradiosound { sound_placement::internal, 2 * EU07_SOUND_CABCONTROLSCUTOFFRANGE }; // provides location and soundproofing of the receiver
public:
float fPress[20][7]; // cisnienia dla wszystkich czlonow