EchoMap 2026-07-25 6d3977c
An experimental cross-platform digital signal processing application for sound-source localisation.
Loading...
Searching...
No Matches
SignalFactory.cpp
Go to the documentation of this file.
1
7
8#include "SignalFactory.hpp"
9
10#define DR_WAV_IMPLEMENTATION
11// ReSharper disable once CppUnusedIncludeDirective
12#include <dr_wav.h>
13
14#include <algorithm>
15#include <cmath>
16#include <ranges>
17
20#include "../Signal.hpp"
21
22namespace echomap
23{
24
26 // Cannot use make_unique here since Signal c'tor is private, and SignalFactory is "just a friend".
27 target(std::unique_ptr<Signal>(new Signal()))
28{
29}
30
31bool SignalFactory::operator==(
32 const SignalFactory& other
33) const
34{
35 if (target.get() == other.target.get())
36 return true;
37
38 if (target == nullptr)
39 return false; // We're null; the other isn't.
40
41 if (other.target == nullptr)
42 return false; // The other is null; we're not.
43
44 // Safe to dereference; compare by IDs.
45 return *target == *other.target;
46}
47
48bool SignalFactory::operator<(
49 const SignalFactory& other
50) const
51{
52 if (target == nullptr || other.target == nullptr || !target->observe_source().has_value() ||
53 !other.target->observe_source().has_value())
54 return false;
55
56 // Safe to deference; attempt to compare by channel and then path.
57 const auto& us = *target->observe_source(); // NOLINT(*-identifier-length)
58 const auto& them = *target->observe_source();
59
60 return us < them;
61}
62
64 const char* const file_path
65)
66{
67 drwav drwav_info;
68 if (drwav_init_file(&drwav_info, file_path, nullptr) == 0u)
69 throw ConfigurationError("Cannot open WAV file at " + std::string(file_path));
70
71 const auto typed_path = std::filesystem::path(file_path);
73 signals.resize(drwav_info.channels);
74
75 std::size_t channel_num = 1;
76 for (auto& channel : signals) {
77 const auto formatted_name = std::format("{}#{}", typed_path.stem().c_str(), channel_num);
78 channel = std::unique_ptr<Signal>(new Signal(formatted_name, Signal::Source(typed_path, channel_num)));
79 ++channel_num;
80 }
81
82 try {
83 std::vector<Signal*> signal_ptrs;
84 signal_ptrs.reserve(signals.size());
86 signals,
87 std::back_inserter(signal_ptrs),
88 [](const std::unique_ptr<Signal>& signal) -> Signal* {
89 return signal.get();
90 }
91 );
92
93 load_wave_file_into_channels(drwav_info, file_path, signal_ptrs);
94 } catch (const std::runtime_error&) {
95 drwav_uninit(&drwav_info);
96 throw;
97 }
98
99 drwav_uninit(&drwav_info);
100 return signals;
101}
102
104 const char* const file_path,
105 const std::span<SignalFactory* const> channel_factories
106)
107{
108 drwav drwav_info;
109 if (drwav_init_file(&drwav_info, file_path, nullptr) == 0u)
110 throw ConfigurationError("Cannot open WAV file at " + std::string(file_path));
111
112 assert(drwav_info.channels >= channel_factories.size());
113
114 try {
115 /*
116 * For convenience of callers, this function takes a span of factories responsible for constructing the signals
117 * for each of the channel slots, rather than the signals themselves. But for portability and simplicity, our
118 * internal functions need the signals directly. Hence, we cheaply construct a span-compliant collection of the
119 * mutating signal pointers, accessible to us as private member variables.
120 */
121 std::vector<Signal*> channels;
122 channels.resize(drwav_info.channels, nullptr);
123
124 std::size_t channel_idx = 0;
125 for (const auto* const factory : channel_factories) {
126 if (factory != nullptr && factory->target != nullptr)
127 channels[channel_idx] = factory->target.get();
128 ++channel_idx;
129 }
130
131 load_wave_file_into_channels(drwav_info, file_path, channels);
132 } catch (const std::runtime_error&) {
133 drwav_uninit(&drwav_info);
134 throw;
135 }
136
137 drwav_uninit(&drwav_info);
138}
139
141 const Signal& source,
142 const float downsample_factor,
143 const std::string_view name
144)
145{
146 auto sample_count = static_cast<std::uint64_t>(static_cast<float>(source.get_sample_count()) / downsample_factor);
147 if (static_cast<float>(sample_count) < downsample_factor)
148 sample_count = static_cast<std::uint64_t>(downsample_factor);
149
150 auto downsampled = name.empty() ? lttb_downsample(
151 source,
152 sample_count,
153 std::format("{} ({}x downsampled)", source.get_name(), downsample_factor)
154 )
155 : lttb_downsample(source, sample_count, name);
156
158 "Created {} as {}x-LTTB variant of {} with {} samples.",
159 downsampled->get_name(),
160 downsample_factor,
161 source.get_name(),
162 downsampled->get_sample_count()
163 );
164
165 return downsampled;
166}
167
168std::unique_ptr<Signal> SignalFactory::take_signal() noexcept
169{
170 auto signal = std::move(target);
171 target = std::unique_ptr<Signal>(new Signal()); // NOLINT(*-unhandled-exception-at-new)
172 return signal;
173}
174
175const Signal& SignalFactory::observe_signal() const noexcept
176{
177 return *target;
178}
179
180void SignalFactory::emplace_sample(
181 const Signal::Sample::AmplitudeT amplitude
182) const
183{
184 target->emplace_sample(amplitude);
185}
186
187void SignalFactory::emplace_sample(
188 const Signal::Sample& sample
189) const
190{
191 target->emplace_sample(sample);
192}
193
194void SignalFactory::emplace_sample(
195 const Signal::Sample::TimeT time,
196 const Signal::Sample::AmplitudeT amplitude
197) const
198{
199 target->emplace_sample(time, amplitude);
200}
201
202void SignalFactory::emplace_sample_from_source(
203 const Signal::Sample::AmplitudeT amplitude
204) const
205{
206 target->emplace_sample_from_source(amplitude);
207}
208
209void SignalFactory::emplace_sample_from_source(
210 const Signal::Sample& sample
211) const
212{
213 target->emplace_sample_from_source(sample);
214}
215
216void SignalFactory::emplace_sample_from_source(
217 const Signal::Sample::TimeT time,
218 const Signal::Sample::AmplitudeT amplitude
219) const
220{
221 target->emplace_sample_from_source(time, amplitude);
222}
223
224void SignalFactory::set_signal_name(
225 const std::string_view name
226) const
227{
228 target->set_name(name);
229}
230
231void SignalFactory::set_time_offset(
232 const Signal::Sample::TimeT time_offset
233) const noexcept
234{
235 target->set_time_offset(time_offset);
236}
237
238void SignalFactory::set_sample_rate(
239 const std::size_t sample_rate
240) const noexcept
241{
242 target->set_sample_rate(sample_rate);
243}
244
245void SignalFactory::set_source(
246 const std::filesystem::path& path,
247 const std::size_t channel
248) const
249{
250 target->set_source(path, channel);
251}
252
254 drwav& drwav_info,
255 const std::string_view file_path,
256 std::span<Signal* const> signal_ptrs
257)
258{
259 assert(drwav_info.channels <= std::ranges::size(signal_ptrs));
260
261 for (auto* const channel : signal_ptrs)
262 if (channel != nullptr) {
263 channel->reserve_samples(drwav_info.totalPCMFrameCount);
264 channel->set_sample_rate(drwav_info.sampleRate);
265 }
266
267 /*
268 * Dr_WAV provides audio data as amplitudes uniformly interleaved across the channels. That is, for a stereo signal,
269 * data is provided in the pattern L0, R0, L1, R1, ..., L(N-1), R(N-1). We receive the interleaved data in chunks of
270 * a fixed size and iteratively de-interleave it into our AudioPoint channels until all frames from the source file
271 * have been consumed.
272 */
273 constexpr drwav_uint64 chunk_frame_count = 8192;
274 std::vector<float> interleaved(chunk_frame_count * drwav_info.channels);
275 drwav_uint64 remaining_frames = drwav_info.totalPCMFrameCount;
276
277 while (remaining_frames > 0) {
278 const auto frame_count = std::min(remaining_frames, chunk_frame_count);
279 if (drwav_read_pcm_frames_f32(&drwav_info, frame_count, interleaved.data()) != frame_count)
280 // We couldn't read the expected number of frames. drwav_init_file must've provided the wrong count.
281 throw ConfigurationError("Cannot read WAV file at " + std::string(file_path) + ". Is it corrupted?");
282
283 for (drwav_uint64 frame_idx = 0; frame_idx < frame_count; ++frame_idx)
284 for (drwav_uint16 channel_idx = 0; channel_idx < drwav_info.channels; ++channel_idx) {
285 if (auto* const destination = std::ranges::begin(signal_ptrs)[channel_idx]; destination != nullptr)
286 /*
287 * The audio data is uniformly spaced, so we can infer the time values by taking the current frame
288 * offset for the chunk (total frames - remaining frames) and adding the current frame index.
289 */
290 destination->emplace_sample_from_source(interleaved[frame_idx * drwav_info.channels + channel_idx]);
291 }
292
293 remaining_frames -= frame_count;
294 }
295
296 if (remaining_frames != 0)
297 throw ConfigurationError("Cannot read entire WAV file at " + std::string(file_path) + ". Is it corrupted?");
298
299 for (auto* const channel : signal_ptrs | std::views::filter([](auto ptr) { return ptr; })) {
300 // Assert that any signal being constructed by these means should have an extant FS source.
301 assert(channel->fs_source.has_value());
302 channel->fs_source->is_loaded = true;
304 "Loaded signal \"{}\" with {} samples at {} Hz, starting at {} s.",
305 channel->get_name(),
306 channel->get_sample_count(),
307 channel->get_sample_rate(),
308 channel->get_time_offset()
309 );
310 }
311}
312
314 const Signal& source,
315 const size_t threshold,
316 const std::string_view name
317)
318{
319 const auto source_size = source.get_sample_count();
320
321 // We don't need to assert for the post-condition on these trivial cases.
322
323 if (threshold == 0 || source_size == 0)
324 // Base case: the user requested zero samples (empty signal), or there were no samples available in the source.
325 return std::unique_ptr<Signal>(new Signal(name));
326
327 if (threshold >= source_size)
328 // Base case: the destination wants more samples than are available. Just copy the source signal.
329 return std::make_unique<Signal>(source, name);
330
331 auto downsampled = std::unique_ptr<Signal>(new Signal(name));
332 downsampled->reserve_samples(threshold);
333
334 if (threshold == 1) {
335 // Base case: the user only wants one sample. We choose the first by convention.
336 downsampled->emplace_sample(source.get_time_at_index(0), source[0]);
337 return downsampled;
338 }
339
340 if (threshold == 2) {
341 // Base case: the user only wants two samples. We choose the first and last by necessity.
342 downsampled->emplace_sample(source.get_time_at_index(0), source[0]);
343 downsampled->emplace_sample(source.get_time_at_index(source_size - 1), source[source_size - 1]);
344 return downsampled;
345 }
346
347 const auto bucket_size =
348 static_cast<unsigned int>(static_cast<double>(source_size - 2) / static_cast<double>(threshold - 2));
349 std::size_t fixed_point_idx = 0;
350
351 // Always add the first point.
352 downsampled->emplace_sample(source.get_time_at_index(0), source[0]);
353
354 for (std::size_t dst_point_idx = 0; dst_point_idx < threshold - 2; ++dst_point_idx) {
355 Signal::Sample::TimeT average_time = 0.0f;
356 Signal::Sample::AmplitudeT average_amplitude = 0.0f;
357
358 // Calculate the point-average for the next bucket, containing our fixed point.
359
360 const auto average_range_start = static_cast<std::size_t>(std::floor((dst_point_idx + 1) * bucket_size)) + 1;
361 const auto average_range_end =
362 std::min(static_cast<std::uint64_t>(std::floor((dst_point_idx + 2) * bucket_size)) + 1, source_size);
363 const auto average_range_length = average_range_end - average_range_start;
364
365 for (auto range_idx = average_range_start; range_idx < average_range_end; ++range_idx) {
366 average_time += source.get_time_at_index(range_idx);
367 average_amplitude += source[range_idx];
368 }
369
370 average_time /= static_cast<Signal::Sample::TimeT>(average_range_length);
371 average_amplitude /= static_cast<Signal::Sample::AmplitudeT>(average_range_length);
372
373 // Store the sample data at the fixed point.
374 const auto fp_time = source.get_time_at_index(fixed_point_idx);
375 const auto fp_amplitude = source[fixed_point_idx];
376
377 // Get the range for the current bucket and compute triangle areas over the three buckets.
378 const auto range_lower = static_cast<std::size_t>(std::floor(dst_point_idx * bucket_size)) + 1;
379 const auto range_upper = std::min(
380 static_cast<std::uint64_t>(std::floor((dst_point_idx + 1) * bucket_size)) + 1,
381 source_size - 1
382 );
383
384 // (C++ note: we need to combine Sample::TimeT and Sample::AmplitudeT here, so float seems like a safe choice.)
385 auto max_area = std::numeric_limits<float>::lowest();
386 auto next_fixed_point_idx = range_lower;
387
388 // Calculate triangle area formed by the vertices in the adjacent buckets, tracking the maximum.
389 for (auto range_idx = range_lower; range_idx < range_upper; ++range_idx) {
390 const float area = std::abs(
391 (fp_time - average_time) * (source[range_idx] - fp_amplitude) -
392 (fp_time - source.get_time_at_index(range_idx)) * (average_amplitude - fp_amplitude)
393 );
394
395 if (area > max_area) {
396 max_area = area;
397 next_fixed_point_idx = range_idx;
398 }
399 }
400
401 /*
402 * Pick the point from the bucket to include in the downsampled data, and set the index as our next starting
403 * point.
404 */
405 downsampled->emplace_sample(source.get_time_at_index(next_fixed_point_idx), source[next_fixed_point_idx]);
406
407 fixed_point_idx = next_fixed_point_idx;
408 }
409
410 // Always add the last point.
411 downsampled->emplace_sample(source.get_time_at_index(source_size - 1), source[source_size - 1]);
412
413 assert(downsampled->get_sample_count() == threshold);
414 return downsampled;
415}
416
417} // namespace echomap
EchoMap ConfigurationError exception specification.
EchoMap portable logger specification.
#define LOG_F_DEBUG(msg,...)
Conditionally logs a formatted debug-level message using echomap::Logger::log_f.
Definition Logger.hpp:94
Wave file specification.
Audio signal class specification.
T back_inserter(T... args)
T begin(T... args)
A ConfigurationError indicates an error encountered during the initial configuration of the EchoMap g...
Provides various convenience functions for constructing Signal objects in exotic ways.
static void load_wave_file_into_channels(drwav &drwav_info, std::string_view file_path, std::span< Signal *const > signal_ptrs)
Loads the time-series sampled data into the given Signal objects.
std::unique_ptr< Signal > target
The Signal being built by the factory.
static std::unique_ptr< Signal > lttb_downsample(const Signal &source, size_t threshold, std::string_view name)
Create a new Signal by downsampling the data points of an existing Signal to the given threshold.
static std::unique_ptr< Signal > downsample(const Signal &source, float downsample_factor, std::string_view name={})
Downsamples an existing Signal instance across all channels to the given number of samples.
SignalFactory()
Begin constructing a new Signal.
static std::vector< std::unique_ptr< Signal > > load_wave_file(const char *file_path)
Loads a WAV file from the file system.
A single channel of discretely sampled audio data.
Definition Signal.hpp:40
std::uint64_t get_sample_count() const noexcept
Retrieves the total number of samples in the Signal stream.
Definition Signal.cpp:38
Sample::TimeT get_time_at_index(std::size_t index) const noexcept
Determines the corresponding time of the amplitude appearing at the given index in the sample array.
Definition Signal.cpp:95
T data(T... args)
T empty(T... args)
T floor(T... args)
T format(T... args)
T lowest(T... args)
T make_unique(T... args)
T min(T... args)
The main EchoMap outermost namespace for all non-exported symbols.
STL namespace.
T reserve(T... args)
T resize(T... args)
T size(T... args)
A PCM float-32 sampled audio point at an explicit time offset.
Definition Signal.hpp:46
float AmplitudeT
Type for sample amplitudes.
Definition Signal.hpp:48
float TimeT
Type for sample times.
Definition Signal.hpp:47
Indicates an external source of a Signal on the filesystem.
Definition Signal.hpp:62
T transform(T... args)