Deepnote Synthesizer Voice Library v1.0.0
A C++14 header-only library implementing the THX Deep Note effect
Loading...
Searching...
No Matches
deepnotevoice.hpp
Go to the documentation of this file.
1
34#pragma once
35
36#include "Synthesis/oscillator.h"
37#include "oscfrequency.hpp"
38#include "ranges/range.hpp"
39#include "ranges/scaler.hpp"
42#include <algorithm>
43#include <array>
44#include <stdexcept>
45#include <string>
46
47namespace deepnote
48{
49namespace constants
50{
51static constexpr float DEFAULT_LFO_AMPLITUDE = 0.5f;
52static constexpr float DEFAULT_DETUNE_HZ = 2.5f;
53static constexpr float TARGET_FREQUENCY_TOLERANCE = 1.0f;
54static constexpr size_t NEAR_BEGINNING_SAMPLES = 4800;
55} // namespace constants
56
57namespace nt
58{
59using SampleRate = NamedType<float, struct SampleRateTag>;
60using AnimationMultiplier = NamedType<float, struct AnimationMultiplierTag>;
61using DetuneHz = NamedType<float, struct DetuneHzTag>;
62using OscillatorFrequencyRange = NamedType<Range, struct OscillatorFrequencyRangeTag>;
63using OscillatorValue = NamedType<float, struct OscillatorValueTag>;
64} // namespace nt
65
66struct NoopTrace
67{
68 template <typename T, typename... Args> void operator()(T first, Args... rest) const {}
69
70 template <typename T> void operator()(T value) const {}
71};
72
91{
92 static constexpr size_t MAX_OSCILLATORS = 16;
93 const float LFO_AMPLITUDE{constants::DEFAULT_LFO_AMPLITUDE};
94
95 enum State
96 {
97 PENDING_TRANSIT_TO_TARGET,
98 IN_TRANSIT_TO_TARGET,
99 AT_TARGET
100 };
101
102 DeepnoteVoice() = default;
103 virtual ~DeepnoteVoice() = default;
104
105 nt::OscillatorFrequency get_target_frequency() const noexcept { return target_frequency; }
106
107 void set_target_frequency(const nt::OscillatorFrequency freq)
108 {
109 if(freq.get() < 0.0f)
110 {
111 throw std::invalid_argument("Target frequency must be non-negative");
112 }
113
114 // set up a new transit from something close to the current frequency of
115 // the voice and the new target frequency
116 this->start_frequency = this->current_frequency;
117 this->target_frequency = freq;
118 this->state = PENDING_TRANSIT_TO_TARGET;
119 }
120
121 nt::OscillatorFrequency get_start_frequency() const noexcept { return start_frequency; }
122
123 void set_start_frequency(const nt::OscillatorFrequency freq)
124 {
125 if(freq.get() < 0.0f)
126 {
127 throw std::invalid_argument("Start frequency must be non-negative");
128 }
129
130 // set up a new transit from a new start frequency
131 this->start_frequency = freq;
132 this->current_frequency = this->start_frequency;
133 this->state = PENDING_TRANSIT_TO_TARGET;
134 }
135
136 nt::OscillatorFrequency get_current_frequency() const noexcept { return current_frequency; }
137
138 void set_current_frequency(const nt::OscillatorFrequency freq) noexcept { this->current_frequency = freq; }
139
140 void scale_lfo_base_freq(const nt::AnimationMultiplier mulitplier)
141 {
142 if(mulitplier.get() < 0.0f)
143 {
144 throw std::invalid_argument("Animation multiplier must be non-negative");
145 }
146
147 lfo.SetFreq(lfo_base_freq.get() * mulitplier.get());
148 }
149
150 nt::OscillatorFrequency get_lfo_base_freq() const noexcept { return lfo_base_freq; }
151
152 void set_lfo_base_freq(const nt::OscillatorFrequency freq) noexcept { this->lfo_base_freq = freq; }
153
154 bool is_at_target() const noexcept { return state == AT_TARGET; }
155
156 State get_state() const noexcept { return state; }
157
158 void set_state(const State state) noexcept { this->state = state; }
159
160 void init_lfo(const nt::SampleRate sample_rate, const nt::OscillatorFrequency base_freq)
161 {
162 if(sample_rate.get() <= 0.0f)
163 {
164 throw std::invalid_argument("Sample rate must be positive");
165 }
166 if(base_freq.get() < 0.0f)
167 {
168 throw std::invalid_argument("LFO base frequency must be non-negative");
169 }
170
171 lfo_base_freq = base_freq;
172 lfo.Init(sample_rate.get());
173 lfo.SetAmp(LFO_AMPLITUDE);
174 lfo.SetWaveform(daisysp::Oscillator::WAVE_RAMP);
175 lfo.SetFreq(lfo_base_freq.get());
176 }
177
178 nt::OscillatorValue process_lfo() { return nt::OscillatorValue(lfo.Process() + LFO_AMPLITUDE); }
179
180 void reset_lfo() noexcept { lfo.Reset(); }
181
182 void init_oscillators(const size_t count, nt::SampleRate sample_rate, nt::OscillatorFrequency start_frequency)
183 {
184 if(count == 0)
185 {
186 throw std::invalid_argument("Oscillator count must be at least 1");
187 }
188 if(count > MAX_OSCILLATORS)
189 {
190 throw std::invalid_argument("Oscillator count exceeds maximum of " + std::to_string(MAX_OSCILLATORS));
191 }
192 if(sample_rate.get() <= 0.0f)
193 {
194 throw std::invalid_argument("Sample rate must be positive");
195 }
196 if(start_frequency.get() < 0.0f)
197 {
198 throw std::invalid_argument("Start frequency must be non-negative");
199 }
200
201 oscillator_count = count;
202 for(size_t i = 0; i < oscillator_count; ++i)
203 {
204 oscillators[i].oscillator.Init(sample_rate.get());
205 oscillators[i].oscillator.SetWaveform(daisysp::Oscillator::WAVE_POLYBLEP_SAW);
206 oscillators[i].oscillator.SetFreq(start_frequency.get());
207 oscillators[i].detune_amount = 0.f;
208 }
209 }
210
221 void detune_oscillators(const nt::DetuneHz detune)
222 {
223 // If we only have one oscillator, we don't need to detune it
224 // Otherwise distribute the either side of the fundamental frequency by an
225 // integer muliples of detune.
226 const auto half = oscillator_count / 2;
227 for(size_t i = 0; i < oscillator_count; ++i)
228 {
229 if(oscillator_count <= 1)
230 {
231 oscillators[i].detune_amount = 0.f;
232 }
233 else
234 {
235 const int8_t idx = i - half + ((i >= half) ? 1 : 0);
236 oscillators[i].detune_amount = idx * detune.get();
237 }
238 }
239 }
240
241 nt::OscillatorValue process_oscillators()
242 {
243 float osc_value{0.f};
244 for(size_t i = 0; i < oscillator_count; ++i)
245 {
246 oscillators[i].oscillator.SetFreq(current_frequency.get() + oscillators[i].detune_amount);
247 osc_value += oscillators[i].oscillator.Process();
248 }
249 return nt::OscillatorValue(osc_value);
250 }
251
252 private:
253 struct DetunedOscillator
254 {
255 daisysp::Oscillator oscillator;
256 float detune_amount;
257 };
258
259 State state{PENDING_TRANSIT_TO_TARGET};
260 nt::OscillatorFrequency start_frequency{0.f};
261 nt::OscillatorFrequency target_frequency{0.f};
262 nt::OscillatorFrequency current_frequency{0.f};
263 std::array<DetunedOscillator, MAX_OSCILLATORS> oscillators{};
264 size_t oscillator_count{0};
265 nt::OscillatorFrequency lfo_base_freq{0.f};
266 daisysp::Oscillator lfo;
267};
268
279inline void init_voice(DeepnoteVoice &voice, const size_t oscillator_count,
280 const nt::OscillatorFrequency start_frequency, const nt::SampleRate sample_rate,
281 const nt::OscillatorFrequency lfo_frequency,
282 const nt::DetuneHz detune = nt::DetuneHz(constants::DEFAULT_DETUNE_HZ))
283{
284 voice.set_start_frequency(start_frequency);
285 voice.set_current_frequency(start_frequency);
286 voice.set_target_frequency(start_frequency);
287 voice.set_state(voice.PENDING_TRANSIT_TO_TARGET);
288 voice.init_lfo(sample_rate, lfo_frequency);
289 voice.init_oscillators(oscillator_count, sample_rate, start_frequency);
290 voice.detune_oscillators(detune);
291}
292
293namespace
294{
295nt::OscillatorFrequency calculate_shaped_frequency(DeepnoteVoice &voice, const nt::AnimationMultiplier lfo_multiplier,
296 const nt::ControlPoint1 cp1, const nt::ControlPoint2 cp2)
297{
298
299 voice.scale_lfo_base_freq(lfo_multiplier);
300 const auto raw_lfo_value = voice.process_lfo();
301 auto shaped_lfo_value = nt::OscillatorValue(BezierUnitShaper(cp1, cp2)(raw_lfo_value.get()));
302
303 const auto start_frequency = voice.get_start_frequency();
304 const auto target_frequency = voice.get_target_frequency();
305
306 // If we want the frequency to decrease we need to flip the shaped value
307 if(start_frequency.get() > target_frequency.get())
308 {
309 shaped_lfo_value = nt::OscillatorValue(1.f - shaped_lfo_value.get());
310 }
311
312 // Scale the 0.0 to 1.0 shaped value to the start and target frequency range
313 const auto animationScaler =
314 Scaler(nt::InputRange(Range(nt::RangeLow(0.f), nt::RangeHigh(1.f))),
315 nt::OutputRange(Range(nt::RangeLow(start_frequency.get()), nt::RangeHigh(target_frequency.get()))));
316
317 return nt::OscillatorFrequency(animationScaler(shaped_lfo_value.get()));
318}
319
320DeepnoteVoice::State update_voice_state(const DeepnoteVoice &voice, const DeepnoteVoice::State current_state,
321 const nt::OscillatorFrequency current_frequency)
322{
323
324 auto state = current_state;
325 const auto start_frequency = voice.get_start_frequency();
326 const auto target_frequency = voice.get_target_frequency();
327
328 // Valid frequencies are from start_frequency to target_frequency
329 // Range constructor ensures low <= high, so we need to handle both directions
330 const auto freq_low = std::min(start_frequency.get(), target_frequency.get());
331 const auto freq_high = std::max(start_frequency.get(), target_frequency.get());
332
333 const auto validFrequencyRange =
334 nt::OscillatorFrequencyRange{Range{nt::RangeLow(freq_low), nt::RangeHigh(freq_high)}};
335
336 if(validFrequencyRange.get().contains(current_frequency.get()))
337 {
338 // The frequency is within the valid range, so check to see if we've
339 // reached the start or target frequency
340 if(state == DeepnoteVoice::IN_TRANSIT_TO_TARGET)
341 {
342 const auto targetRange = nt::OscillatorFrequencyRange{
343 Range{nt::RangeLow(target_frequency.get() - constants::TARGET_FREQUENCY_TOLERANCE),
344 nt::RangeHigh(target_frequency.get() + constants::TARGET_FREQUENCY_TOLERANCE)}};
345
346 if(targetRange.get().contains(current_frequency.get()))
347 {
348 state = DeepnoteVoice::AT_TARGET;
349 }
350 }
351 }
352 else
353 {
354 // The frequency is outside the valid range, so constrain it
355 state = (state == DeepnoteVoice::IN_TRANSIT_TO_TARGET) ? DeepnoteVoice::AT_TARGET : state;
356 }
357
358 return state;
359}
360
361nt::OscillatorFrequency constrain_frequency(const DeepnoteVoice &voice, const nt::OscillatorFrequency frequency)
362{
363
364 const auto start_frequency = voice.get_start_frequency();
365 const auto target_frequency = voice.get_target_frequency();
366
367 // Range constructor ensures low <= high, so we need to handle both directions
368 const auto freq_low = std::min(start_frequency.get(), target_frequency.get());
369 const auto freq_high = std::max(start_frequency.get(), target_frequency.get());
370
371 const auto validFrequencyRange =
372 nt::OscillatorFrequencyRange{Range{nt::RangeLow(freq_low), nt::RangeHigh(freq_high)}};
373
374 return nt::OscillatorFrequency(validFrequencyRange.get().constrain(frequency.get()));
375}
376} // namespace
377
392template <typename TraceFunc = NoopTrace>
393nt::OscillatorValue process_voice(DeepnoteVoice &voice, const nt::AnimationMultiplier lfo_multiplier,
394 const nt::ControlPoint1 cp1, const nt::ControlPoint2 cp2,
395 const TraceFunc &trace_functor = NoopTrace())
396{
397 nt::OscillatorFrequency unconstrained_freq(0.f); // only used for tracing
398 const auto in_state{voice.get_state()};
399 auto state = in_state;
400
401 // if we in a pending state, reset the animation LFO and move to the next state
402 if(state == DeepnoteVoice::PENDING_TRANSIT_TO_TARGET)
403 {
404 voice.reset_lfo();
405 state = DeepnoteVoice::IN_TRANSIT_TO_TARGET;
406 }
407
408 const auto start_frequency = voice.get_start_frequency();
409 const auto target_frequency = voice.get_target_frequency();
410 nt::OscillatorFrequency current_frequency(0.0f);
411
412 if(state == DeepnoteVoice::AT_TARGET)
413 {
414 current_frequency = target_frequency;
415 }
416 else
417 {
418 current_frequency = calculate_shaped_frequency(voice, lfo_multiplier, cp1, cp2);
419 unconstrained_freq = current_frequency;
420 state = update_voice_state(voice, state, current_frequency);
421 current_frequency = constrain_frequency(voice, current_frequency);
422
423 // If we reached target after constraining, set exact target frequency
424 if(state == DeepnoteVoice::AT_TARGET)
425 {
426 current_frequency = target_frequency;
427 }
428 }
429
430 voice.set_current_frequency(current_frequency);
431 voice.set_state(state);
432
433 // Update all oscillators using the new frequency
434 nt::OscillatorValue osc_value = voice.process_oscillators();
435
436 // Give the traceFunctor a chance to log the state of the voice
437 trace_functor(start_frequency.get(), target_frequency.get(), in_state, state,
438 0.0f, // raw_lfo_value - simplified for now
439 0.0f, // shaped_lfo_value - simplified for now
440 unconstrained_freq.get(), current_frequency.get(), osc_value.get());
441
442 return osc_value;
443}
444} // namespace deepnote
Bezier curve shaping utilities for the Deep Note synthesizer.
void init_voice(DeepnoteVoice &voice, const size_t oscillator_count, const nt::OscillatorFrequency start_frequency, const nt::SampleRate sample_rate, const nt::OscillatorFrequency lfo_frequency, const nt::DetuneHz detune=nt::DetuneHz(constants::DEFAULT_DETUNE_HZ))
Initialize a DeepnoteVoice with specified parameters.
nt::OscillatorValue process_voice(DeepnoteVoice &voice, const nt::AnimationMultiplier lfo_multiplier, const nt::ControlPoint1 cp1, const nt::ControlPoint2 cp2, const TraceFunc &trace_functor=NoopTrace())
Process a single audio sample from the voice.
Frequency table implementation for voice management in the Deep Note synthesizer.
Oscillator frequency calculation utilities for the Deep Note synthesizer.
Range constraint and validation utilities for the Deep Note synthesizer.
Value scaling and mapping utilities for the Deep Note synthesizer.
A synthesizer voice implementing the THX Deep Note effect.
void detune_oscillators(const nt::DetuneHz detune)
Detune oscillators symmetrically around the fundamental frequency.