Skip to content

Commit 28dcfef

Browse files
committed
refactor: Improve passaggio calculation and dissonance detection context awareness
Enhance music theory utilities with better context-sensitive behavior: Passaggio calculation: - Add vocal_low and vocal_high fields to TessituraRange struct - Use dynamic passaggio calculation based on actual vocal range instead of fixed zones (isInPassaggioRange replaces isInPassaggio) - Update getComfortScore() and nearestChordToneWithinInterval() to use dynamic calculation Dissonance detection: - Add simultaneous parameter to isDissonantIntervalWithContext() - Major 2nd (2 semitones) is now only dissonant for vertical intervals (simultaneous notes), not for melodic/horizontal intervals where it represents a natural scale step Chord tension expansion: - iii minor chord now includes b13th as available tension (natural 13th from scale = minor 6th from root) Bass pattern selection: - Extract selectPatternWithPolicyCore() template function to eliminate code duplication between selectPatternWithPolicy() and selectPatternWithPolicyForVocal() MIDI writer: - Remove debug assertions for meta text length (silent truncation is acceptable behavior for production) Tests: - Update all TessituraRange initializations to include vocal range fields
1 parent 55fc600 commit 28dcfef

13 files changed

Lines changed: 100 additions & 103 deletions

src/core/chord_utils.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ std::vector<int> getAvailableTensionPitchClasses(int8_t degree) {
9292
// Available tensions by degree (in semitones from root):
9393
// I (0): 9th (+2), 13th (+9) - avoid 11th (#4 clashes with 3rd)
9494
// ii (1): 9th (+2), 11th (+5), 13th (+9)
95-
// iii (2): 11th (+5) - avoid 9th (b9), avoid 13th (b13)
95+
// iii (2): 11th (+5), b13th (+8) - avoid 9th (b9)
9696
// IV (3): 9th (+2), #11th (+6), 13th (+9)
9797
// V (4): 9th (+2), 13th (+9) - 11th only if sus4
9898
// vi (5): 9th (+2), 11th (+5) - avoid 13th (b13)
@@ -110,6 +110,8 @@ std::vector<int> getAvailableTensionPitchClasses(int8_t degree) {
110110
break;
111111
case 2: // iii minor
112112
result.push_back((root_pc + 5) % 12); // 11th
113+
result.push_back((root_pc + 8) %
114+
12); // b13th (natural 13th from scale = minor 6th from root)
113115
break;
114116
case 3: // IV major
115117
result.push_back((root_pc + 2) % 12); // 9th
@@ -211,7 +213,9 @@ int nearestChordToneWithinInterval(int target_pitch, int prev_pitch, int8_t chor
211213
if (candidate >= tessitura->low && candidate <= tessitura->high) {
212214
score += 15; // Bonus for being in tessitura
213215
}
214-
if (isInPassaggio(static_cast<uint8_t>(candidate))) {
216+
// Use dynamic passaggio calculation based on vocal range
217+
if (isInPassaggioRange(static_cast<uint8_t>(candidate), tessitura->vocal_low,
218+
tessitura->vocal_high)) {
215219
score -= 5;
216220
}
217221
}

src/core/pitch_utils.cpp

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ TessituraRange calculateTessitura(uint8_t vocal_low, uint8_t vocal_high) {
2222
t.low = static_cast<uint8_t>(vocal_low + margin);
2323
t.high = static_cast<uint8_t>(vocal_high - margin);
2424
t.center = static_cast<uint8_t>((t.low + t.high) / 2);
25+
t.vocal_low = vocal_low;
26+
t.vocal_high = vocal_high;
2527

2628
// Ensure valid range
2729
if (t.low >= t.high) {
@@ -38,7 +40,7 @@ bool isInTessitura(uint8_t pitch, const TessituraRange& tessitura) {
3840
}
3941

4042
float getComfortScore(uint8_t pitch, const TessituraRange& tessitura, uint8_t vocal_low,
41-
uint8_t /* vocal_high */) {
43+
uint8_t vocal_high) {
4244
// Perfect score for tessitura center
4345
if (pitch == tessitura.center) return 1.0f;
4446

@@ -51,8 +53,8 @@ float getComfortScore(uint8_t pitch, const TessituraRange& tessitura, uint8_t vo
5153
return 0.8f + 0.2f * (1.0f - static_cast<float>(dist_from_center) / tessitura_half);
5254
}
5355

54-
// Reduced score for passaggio
55-
if (isInPassaggio(pitch)) {
56+
// Reduced score for passaggio (dynamically calculated based on voice range)
57+
if (isInPassaggioRange(pitch, vocal_low, vocal_high)) {
5658
return 0.4f;
5759
}
5860

@@ -140,7 +142,7 @@ bool isDissonantInterval(int pc1, int pc2) {
140142
return interval == 1 || interval == 6;
141143
}
142144

143-
bool isDissonantIntervalWithContext(int pc1, int pc2, int8_t chord_degree) {
145+
bool isDissonantIntervalWithContext(int pc1, int pc2, int8_t chord_degree, bool simultaneous) {
144146
int interval = std::abs(pc1 - pc2);
145147
if (interval > 6) interval = 12 - interval;
146148

@@ -149,10 +151,10 @@ bool isDissonantIntervalWithContext(int pc1, int pc2, int8_t chord_degree) {
149151
return true;
150152
}
151153

152-
// Major 2nd (2) is dissonant when tracks overlap
153-
// While acceptable as passing tone or tension within a chord,
154-
// it sounds harsh when Chord and Vocal play simultaneously
155-
if (interval == 2) {
154+
// Major 2nd (2) is dissonant only for simultaneous (vertical) intervals.
155+
// In melodic (horizontal) context, it's a natural scale step and acceptable.
156+
// When tracks play at the same time, M2 creates audible beating.
157+
if (interval == 2 && simultaneous) {
156158
return true;
157159
}
158160

src/core/pitch_utils.h

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,11 @@ inline uint8_t transposePitch(uint8_t pitch, Key key) {
197197

198198
/// @brief Tessitura: The comfortable singing range within full vocal range.
199199
struct TessituraRange {
200-
uint8_t low; ///< Lower bound of comfortable range
201-
uint8_t high; ///< Upper bound of comfortable range
202-
uint8_t center; ///< Center of tessitura (optimal pitch)
200+
uint8_t low; ///< Lower bound of comfortable range
201+
uint8_t high; ///< Upper bound of comfortable range
202+
uint8_t center; ///< Center of tessitura (optimal pitch)
203+
uint8_t vocal_low; ///< Full vocal range lower bound (for passaggio calculation)
204+
uint8_t vocal_high; ///< Full vocal range upper bound (for passaggio calculation)
203205
};
204206

205207
/**
@@ -282,14 +284,18 @@ bool isDissonantInterval(int pc1, int pc2);
282284
/**
283285
* @brief Check for dissonance with chord context awareness.
284286
*
285-
* Tritone is allowed on V chord (dominant function).
287+
* - Minor 2nd (1): always dissonant (harsh beating)
288+
* - Major 2nd (2): dissonant only for simultaneous (vertical) intervals
289+
* - Tritone (6): allowed on V chord (dominant function) and vii° chord
286290
*
287291
* @param pc1 First pitch class (0-11)
288292
* @param pc2 Second pitch class (0-11)
289293
* @param chord_degree Current chord's scale degree (0=I, 4=V, etc.)
294+
* @param simultaneous true for vertical (same-time) intervals, false for melodic
290295
* @return true if interval is dissonant in this harmonic context
291296
*/
292-
bool isDissonantIntervalWithContext(int pc1, int pc2, int8_t chord_degree);
297+
bool isDissonantIntervalWithContext(int pc1, int pc2, int8_t chord_degree,
298+
bool simultaneous = true);
293299

294300
/**
295301
* @brief Check if an actual semitone interval is dissonant (Pop theory).

src/midi/midi_writer.cpp

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,7 @@ void MidiWriter::writeTrack(const MidiTrack& track, const std::string& name, uin
8686
// Defensive: BPM should be validated at buildSMF1() entry point
8787
if (bpm == 0) bpm = 120;
8888

89-
// Track name (Meta event 0x03) - truncate if too long
90-
// NOTE: Truncation is silent; assert in debug builds to catch oversized names
91-
assert(name.size() <= kMaxMetaTextLength && "Track name exceeds MIDI meta text limit");
89+
// Track name (Meta event 0x03) - truncate if too long for MIDI meta text limit
9290
std::string track_name =
9391
name.size() > kMaxMetaTextLength ? name.substr(0, kMaxMetaTextLength) : name;
9492
track_data.push_back(0x00);
@@ -249,9 +247,7 @@ void MidiWriter::writeMarkerTrack(const MidiTrack& track, uint16_t bpm,
249247
Tick delta = marker.time - prev_time;
250248
prev_time = marker.time;
251249

252-
// Truncate marker text if too long
253-
// NOTE: Truncation is silent; assert in debug builds to catch oversized text
254-
assert(marker.text.size() <= kMaxMetaTextLength && "Marker text exceeds MIDI meta text limit");
250+
// Truncate marker text if too long for MIDI meta text limit
255251
std::string marker_text = marker.text.size() > kMaxMetaTextLength
256252
? marker.text.substr(0, kMaxMetaTextLength)
257253
: marker.text;

src/track/aux_track.h

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,13 @@ class AuxTrackGenerator {
134134
public:
135135
/// Context for aux generation.
136136
struct AuxContext {
137-
Tick section_start = 0; ///< Absolute start tick of the section
138-
Tick section_end = 0; ///< Absolute end tick of the section
139-
int8_t chord_degree = 0; ///< Starting chord degree (0-based scale degree)
140-
int key_offset = 0; ///< Key offset from C major (for transposition)
141-
uint8_t base_velocity = 100; ///< Base MIDI velocity for notes
142-
TessituraRange main_tessitura = {60, 72, 66}; ///< Main melody's comfortable range
143-
const std::vector<NoteEvent>* main_melody = nullptr; ///< Reference to main melody notes
137+
Tick section_start = 0; ///< Absolute start tick of the section
138+
Tick section_end = 0; ///< Absolute end tick of the section
139+
int8_t chord_degree = 0; ///< Starting chord degree (0-based scale degree)
140+
int key_offset = 0; ///< Key offset from C major (for transposition)
141+
uint8_t base_velocity = 100; ///< Base MIDI velocity for notes
142+
TessituraRange main_tessitura = {60, 72, 66, 55, 77}; ///< Main melody's comfortable range
143+
const std::vector<NoteEvent>* main_melody = nullptr; ///< Reference to main melody notes
144144
/// Phrase boundaries from vocal generation (for breath coordination)
145145
const std::vector<PhraseBoundary>* phrase_boundaries = nullptr;
146146
SectionType section_type =

src/track/bass.cpp

Lines changed: 32 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -405,18 +405,25 @@ BassPattern selectPattern(SectionType section, bool drums_enabled, Mood mood,
405405
return selected;
406406
}
407407

408-
/// Select pattern based on RiffPolicy, using cache for Locked/Evolving modes.
408+
// ============================================================================
409+
// RiffPolicy Pattern Selection (Generic Template)
410+
// ============================================================================
411+
412+
/// Core implementation of pattern selection with RiffPolicy support.
413+
/// Extracts common logic for Locked/Evolving/Free mode handling.
414+
/// @tparam PatternSelector Callable returning BassPattern (invoked for new selection)
409415
/// @param cache Riff cache to store/retrieve cached pattern
410-
/// @param section Current section info
411416
/// @param sec_idx Current section index
412417
/// @param params Generator parameters (contains riff_policy)
413418
/// @param rng Random number generator
419+
/// @param selector Callable that returns a new pattern when selection is needed
414420
/// @return Selected bass pattern
415-
BassPattern selectPatternWithPolicy(BassRiffCache& cache, const Section& section, size_t sec_idx,
416-
const GeneratorParams& params, std::mt19937& rng) {
421+
template <typename PatternSelector>
422+
BassPattern selectPatternWithPolicyCore(BassRiffCache& cache, size_t sec_idx,
423+
const GeneratorParams& params, std::mt19937& rng,
424+
PatternSelector&& selector) {
417425
BassPattern pattern;
418426

419-
// Check RiffPolicy
420427
RiffPolicy policy = params.riff_policy;
421428

422429
// Handle Locked variants (LockedContour, LockedPitch, LockedAll) as same behavior
@@ -431,18 +438,15 @@ BassPattern selectPatternWithPolicy(BassRiffCache& cache, const Section& section
431438
std::uniform_real_distribution<float> evolve_dist(0.0f, 1.0f);
432439
if (sec_idx % 2 == 0 && evolve_dist(rng) < 0.3f) {
433440
// Allow evolution - select new pattern
434-
pattern = selectPattern(section.type, params.drums_enabled, params.mood,
435-
section.backing_density, rng);
436-
// Update cache with evolved pattern
441+
pattern = selector();
437442
cache.pattern = pattern;
438443
} else {
439444
// Keep using cached pattern
440445
pattern = cache.pattern;
441446
}
442447
} else {
443448
// Free: select pattern normally (per-section)
444-
pattern = selectPattern(section.type, params.drums_enabled, params.mood,
445-
section.backing_density, rng);
449+
pattern = selector();
446450
}
447451

448452
// Cache the first valid pattern for Locked/Evolving modes
@@ -454,6 +458,21 @@ BassPattern selectPatternWithPolicy(BassRiffCache& cache, const Section& section
454458
return pattern;
455459
}
456460

461+
/// Select pattern based on RiffPolicy, using cache for Locked/Evolving modes.
462+
/// @param cache Riff cache to store/retrieve cached pattern
463+
/// @param section Current section info
464+
/// @param sec_idx Current section index
465+
/// @param params Generator parameters (contains riff_policy)
466+
/// @param rng Random number generator
467+
/// @return Selected bass pattern
468+
BassPattern selectPatternWithPolicy(BassRiffCache& cache, const Section& section, size_t sec_idx,
469+
const GeneratorParams& params, std::mt19937& rng) {
470+
return selectPatternWithPolicyCore(cache, sec_idx, params, rng, [&]() {
471+
return selectPattern(section.type, params.drums_enabled, params.mood, section.backing_density,
472+
rng);
473+
});
474+
}
475+
457476
// Helper to add a bass note with safety check against vocal
458477
// If the desired pitch clashes, uses harmony context to find safe alternative
459478
// IMPORTANT: For bass, the result must always be a chord tone to define harmony
@@ -1181,40 +1200,9 @@ BassPattern selectPatternForVocalDensity(float vocal_density, SectionType sectio
11811200
BassPattern selectPatternWithPolicyForVocal(BassRiffCache& cache, const Section& section,
11821201
size_t sec_idx, const GeneratorParams& params,
11831202
float vocal_density, std::mt19937& rng) {
1184-
BassPattern pattern;
1185-
1186-
// Check RiffPolicy
1187-
RiffPolicy policy = params.riff_policy;
1188-
1189-
// Handle Locked variants as same behavior
1190-
bool is_locked = (policy == RiffPolicy::LockedContour || policy == RiffPolicy::LockedPitch ||
1191-
policy == RiffPolicy::LockedAll);
1192-
1193-
if (is_locked && cache.cached) {
1194-
// Locked: always use cached pattern
1195-
pattern = cache.pattern;
1196-
} else if (policy == RiffPolicy::Evolving && cache.cached) {
1197-
// Evolving: 30% chance to select new pattern every 2 sections
1198-
std::uniform_real_distribution<float> evolve_dist(0.0f, 1.0f);
1199-
if (sec_idx % 2 == 0 && evolve_dist(rng) < 0.3f) {
1200-
// Allow evolution - select new pattern based on vocal density
1201-
pattern = selectPatternForVocalDensity(vocal_density, section.type, params.mood, rng);
1202-
cache.pattern = pattern;
1203-
} else {
1204-
pattern = cache.pattern;
1205-
}
1206-
} else {
1207-
// Free: select pattern based on vocal density
1208-
pattern = selectPatternForVocalDensity(vocal_density, section.type, params.mood, rng);
1209-
}
1210-
1211-
// Cache the first valid pattern
1212-
if (!cache.cached) {
1213-
cache.pattern = pattern;
1214-
cache.cached = true;
1215-
}
1216-
1217-
return pattern;
1203+
return selectPatternWithPolicyCore(cache, sec_idx, params, rng, [&]() {
1204+
return selectPatternForVocalDensity(vocal_density, section.type, params.mood, rng);
1205+
});
12181206
}
12191207

12201208
// Helper: motion type to string for logging

tests/core/chord_utils_test.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ TEST(ChordUtilsTest, NearestChordToneWithinIntervalNoPrev) {
185185
}
186186

187187
TEST(ChordUtilsTest, NearestChordToneWithinIntervalWithTessitura) {
188-
TessituraRange t{60, 72, 66};
188+
TessituraRange t{60, 72, 66, 55, 77};
189189

190190
// Target G4 (67), prev E4 (64), max interval 7, I chord
191191
// Both E4 and G4 are chord tones and within tessitura

tests/core/melody_evaluator_test.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ MelodyDesigner::SectionContext createTestSectionContext(SectionType type, Tick s
275275
ctx.section_bars = bars;
276276
ctx.chord_degree = 0;
277277
ctx.key_offset = 0;
278-
ctx.tessitura = {60, 79, 69}; // C4-G5, center ~A4
278+
ctx.tessitura = {60, 79, 69, 60, 79}; // C4-G5, center ~A4
279279
ctx.vocal_low = 60;
280280
ctx.vocal_high = 79;
281281
ctx.density_modifier = 1.0f;

tests/core/pitch_utils_test.cpp

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ TEST(PitchUtilsTest, CalculateTessituraNarrowRange) {
4949
}
5050

5151
TEST(PitchUtilsTest, IsInTessitura) {
52-
TessituraRange t{60, 72, 66};
52+
TessituraRange t{60, 72, 66, 55, 77};
5353

5454
EXPECT_TRUE(isInTessitura(60, t)); // Low boundary
5555
EXPECT_TRUE(isInTessitura(66, t)); // Center
@@ -72,11 +72,12 @@ TEST(PitchUtilsTest, GetComfortScoreInTessitura) {
7272
}
7373

7474
TEST(PitchUtilsTest, GetComfortScorePassaggio) {
75-
// Create a tessitura that excludes the passaggio zone
76-
// Passaggio is 64-71, so use tessitura above it
77-
TessituraRange t{72, 82, 77}; // Tessitura above passaggio
78-
// Pitch 68 (G#4) is in passaggio but outside tessitura
79-
float score = getComfortScore(68, t, 60, 85);
75+
// Dynamic passaggio calculation: 55%-75% of vocal range
76+
// For vocal_low=50, vocal_high=80: range=30, passaggio=50+16=66 to 50+22=72
77+
// Create tessitura that excludes the passaggio zone
78+
TessituraRange t{74, 80, 77, 50, 80}; // Tessitura above passaggio (66-72)
79+
// Pitch 69 is in dynamic passaggio zone (66-72) but outside tessitura
80+
float score = getComfortScore(69, t, 50, 80);
8081
EXPECT_FLOAT_EQ(score, 0.4f); // Reduced score for passaggio
8182
}
8283

tests/track/aux_chorus_behavior_test.cpp

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ TEST(AuxChorusBehaviorTest, ChorusAuxUsesChordTones) {
8080
ctx.chord_degree = 0;
8181
ctx.key_offset = 0;
8282
ctx.base_velocity = 100;
83-
ctx.main_tessitura = {72, 84, 78}; // High vocal tessitura (C5-C6)
83+
ctx.main_tessitura = {72, 84, 78, 67, 89}; // High vocal tessitura (C5-C6)
8484
ctx.main_melody = &vocal_melody;
8585

8686
// Configure as EmotionalPad (what Chorus should use)
@@ -150,7 +150,7 @@ TEST(AuxChorusBehaviorTest, ChorusAuxInLowerRegisterThanVocal) {
150150
ctx.chord_degree = 0;
151151
ctx.key_offset = 0;
152152
ctx.base_velocity = 100;
153-
ctx.main_tessitura = {72, 84, 78};
153+
ctx.main_tessitura = {72, 84, 78, 67, 89};
154154
ctx.main_melody = &vocal_melody;
155155

156156
AuxConfig config;
@@ -203,7 +203,7 @@ TEST(AuxChorusBehaviorTest, ChorusAuxNoExactUnisonWithVocal) {
203203
ctx.chord_degree = 0;
204204
ctx.key_offset = 0;
205205
ctx.base_velocity = 100;
206-
ctx.main_tessitura = {72, 84, 78};
206+
ctx.main_tessitura = {72, 84, 78, 67, 89};
207207
ctx.main_melody = &vocal_melody;
208208

209209
// Using EmotionalPad (correct behavior)
@@ -264,7 +264,7 @@ TEST(AuxChorusBehaviorTest, UnisonFunctionCreatesExactMatches) {
264264
ctx.chord_degree = 0;
265265
ctx.key_offset = 0;
266266
ctx.base_velocity = 100;
267-
ctx.main_tessitura = {72, 84, 78};
267+
ctx.main_tessitura = {72, 84, 78, 67, 89};
268268
ctx.main_melody = &vocal_melody;
269269

270270
// Using Unison (what we want to AVOID in Chorus)
@@ -321,7 +321,7 @@ TEST(AuxChorusBehaviorTest, EmotionalPadProducesSustainedNotes) {
321321
ctx.chord_degree = 0;
322322
ctx.key_offset = 0;
323323
ctx.base_velocity = 100;
324-
ctx.main_tessitura = {72, 84, 78};
324+
ctx.main_tessitura = {72, 84, 78, 67, 89};
325325
ctx.main_melody = &vocal_melody;
326326

327327
AuxConfig config;

0 commit comments

Comments
 (0)