Case Study VII: Bird Im-Migration Ensemble#
bird_im_migration_ensemble takes the analysis-driven material from Case Study VI: Bird Im-Migration and turns it into a short chamber piece with an appended transform study.
The earlier package is a proof of concept for reducing bird-like partial activity into playable notation.
The ensemble package keeps that reduction as its source library, but it stops trying to remain purely spectral.
Instead, it treats the bird fragments as modular motives and places them inside a more obviously composed environment: alternating treble calls, a low piano drone with patterned variation, and percussion that behaves like an environmental pulse rather than a literal transcription of the recording.
The current score also ends with appendix-style demonstrations that isolate each curated bird region and show how the transformation pipeline alters it.
This case study shows a second stage of the same project. The spectral analysis is still there. The curated regions are still there. But the composition is no longer only a reduction of recorded sound. It becomes a score system that can support performance, transformation, substitution of instruments, movement-level formal planning, and a documented appendix for transform debugging.
Download#
Format |
Link |
Duration |
|---|---|---|
n/a |
||
WAV |
9:29 |
|
Movement I WAV |
1:30 |
|
Movement II WAV |
1:44 |
|
Movement III WAV |
1:32 |
|
Appendix A1 WAV |
1:11 |
|
Appendix A2 WAV |
1:11 |
|
Appendix A3 WAV |
1:11 |
|
Appendix A4 WAV |
1:11 |
Listen#
Full Piece#
Duration: 9:29
Movement I#
Duration: 1:30
Movement II#
Duration: 1:44
Movement III#
Duration: 1:32
Appendix A1#
Duration: 1:11
Appendix A2#
Duration: 1:11
Appendix A3#
Duration: 1:11
Appendix A4#
Duration: 1:11
Score Preview#
From Spectral Reduction to Modular Phrases#
The raw material is still the same field recording and the same curated regions introduced in the original Bird Im-Migration chapter. The difference is that the ensemble package does not treat those regions as a finished score. It converts them into a phrase library. Each phrase remembers which sample and which curated region it came from. This keeps later transformations attached to a recognizable source motive rather than turning them into anonymous note sequences.
The core data structures and the default movement plans live together in the generator module:
DEFAULT_SOURCE = BirdSource(
sample_id="parkbirds",
partials_path=str(DEFAULT_PARTIALS_PATH),
curated_regions=CURATED_BIRD_REGIONS,
)
DEFAULT_MOVEMENTS: tuple[MovementConfig, ...] = (
MovementConfig(
number=1,
title="I. Opening Call",
subtitle="Bird-call fragments introduced in measured exchange",
time_signature=(4, 4),
tempo_bpm=88,
key_literal=r"\key d \dorian",
center_midi=50,
total_measures=32,
phrase_pairs=6,
intro_measures=2,
closing_measures=3,
phrase_measures=1,
call_transforms=("identity", "repeat"),
response_transforms=("identity", "retrograde"),
call_response_probability=0.95,
percussion_density=0.18,
percussion_pattern="son-clave",
piano_pattern="two-beat",
seed_offset=0,
),
MovementConfig(
number=2,
title="II. Echo / Nocturne",
subtitle="Slower transformation and expanded time",
time_signature=(3, 4),
tempo_bpm=57,
key_literal=r"\key b \minor",
center_midi=47,
total_measures=32,
phrase_pairs=6,
intro_measures=1,
closing_measures=3,
phrase_measures=1,
call_transforms=("augment", "identity"),
response_transforms=("retrograde", "augment"),
call_response_probability=0.9,
percussion_density=0.12,
percussion_pattern="habanera-wave",
piano_pattern="charleston",
seed_offset=29,
),
MovementConfig(
number=3,
title="III. Last Calls",
subtitle="Layered returns and controlled closure",
time_signature=(5, 4),
tempo_bpm=108,
key_literal=r"\key d \minor",
center_midi=50,
total_measures=32,
phrase_pairs=7,
intro_measures=2,
closing_measures=3,
phrase_measures=1,
call_transforms=("identity", "retrograde", "repeat"),
response_transforms=("identity", "retrograde", "augment"),
call_response_probability=1.0,
percussion_density=0.28,
percussion_pattern="cascara-light",
piano_pattern="syncopated",
seed_offset=71,
),
MovementConfig(
number=4,
title="IV. Spectral Analysis of Birds and Transform Debugging",
subtitle="Singles, pairs, and triples of transforms with semitone pitch shifts",
time_signature=(4, 4),
tempo_bpm=60,
key_literal=r"\key c \major",
center_midi=74,
total_measures=24,
phrase_pairs=0,
intro_measures=0,
closing_measures=0,
phrase_measures=1,
call_transforms=(),
response_transforms=(),
call_response_probability=0.0,
percussion_density=0.0,
percussion_pattern="son-clave",
piano_pattern="two-beat",
seed_offset=0,
),
)
PERCUSSION_PATTERNS: dict[str, tuple[int, ...]] = {
"son-clave": (0, 3, 6, 10, 12, 19, 22, 26),
"habanera-wave": (0, 3, 4, 6, 8, 12, 15, 16, 18, 20, 24, 27, 28, 30),
"cascara-light": (0, 2, 5, 7, 8, 11, 12, 14, 17, 19, 20, 23, 24, 26, 29, 31),
}
PIANO_PATTERNS: dict[str, tuple[int, ...]] = {
"two-beat": (4, 12),
"charleston": (0, 6),
"anticipation": (14,),
"syncopated": (2, 6),
}
In practice this means the composition can be described at the level of phrase behavior rather than note-by-note editing. The movement configuration chooses tempo, meter, pitch center, phrase count, percussion density, and the allowed transformations for calls and responses. That is the main difference between this package and the earlier spectral reduction package. The new piece is still derived from the bird analysis, but it is shaped as a parametric form.
Phrase Extraction and Transformation#
The phrase library is built directly from the curated spectral regions. Each region is quantized onto a 16th-note grid and stored as one modular phrase object. From there, the package creates variants using a deliberately small set of operations: identity, retrograde, augmentation, repetition, and semitone pitch shifts.
def _make_variant(
phrase: Phrase,
transform_names: str | tuple[str, ...],
*,
measure_units: int,
phrase_measures: int,
) -> PhraseVariant:
normalized_transform_names = _normalize_transform_names(transform_names)
bins = phrase.note_bins
span_measures = phrase_measures
for transform_name in normalized_transform_names:
bins, span_measures = _apply_transform(
bins,
span_measures,
transform_name,
phrase_measures=phrase_measures,
)
bins = _resample_bins(bins, target_units=measure_units * span_measures)
return PhraseVariant(
phrase=phrase,
transform_names=normalized_transform_names,
note_bins=bins,
span_measures=span_measures,
)
def build_phrase_library(
*,
sources: Iterable[BirdSource] = (DEFAULT_SOURCE,),
bins_per_phrase: int = 16,
) -> tuple[Phrase, ...]:
phrases: list[Phrase] = []
for source in sources:
partials = parse_spear_partials(source.partials_path)
for region_name, region in source.curated_regions:
note_bins = tuple(
quantize_region_pitches(partials, region, bins_per_measure=bins_per_phrase)
)
phrase_id = f"{source.sample_id}:{region_name.lower().replace(' ', '-')}"
phrases.append(
Phrase(
phrase_id=phrase_id,
sample_id=source.sample_id,
region_name=region_name,
note_bins=note_bins,
)
)
return tuple(phrases)
This is the central compositional decision in the package. The score does not ask the spectral analysis to solve the whole piece. Instead, the analysis contributes a motive bank. The movement planner then decides whether a phrase should be stated plainly, reversed, stretched, repeated, transposed by semitone steps, or combined into short transform chains. That keeps the birdsong recognizable but also lets the piece behave like chamber music rather than a transcription exercise. The main movements use that bank for call-and-response writing, while the appendix sections label the transformations directly so the phrase manipulations can be inspected one region at a time.
Appendix Study: Transform Demonstration#
After the three main movements, the score now continues with four appendix sections:
Appendix A1. Demonstration of transforms on Early birdsAppendix A2. Demonstration of transforms on Middle birdsAppendix A3. Demonstration of transforms on Strong middle/late birdsAppendix A4. Demonstration of transforms on Late birds
Each appendix section uses the same mapped bird-note material as the main ensemble writing.
It then applies the transform pipeline in a fixed order so the effects can be inspected directly on a single treble staff.
The current study sequence includes single transforms, repeated augmentation, transform pairs, and transform triples with semitone pitch operations such as pitch+1 or pitch-2.
These appendix sections are not meant as new ensemble movements in the dramatic sense. They function more like documented analytical demonstrations: the same phrase source, the same transform engine, but with a stripped-down notation context that makes the algorithmic choices easier to read.
How the Bird Lines Are Created#
The violin and trumpet lines are built from the same pool of phrase variants. The first excerpt shows the call-and-response loop itself. The generator chooses a phrase for the call, maps it into the violin register, and then optionally answers it with a transformed phrase in the trumpet register. The mapping is intentionally asymmetric: the higher line prefers the upper note in each bin, while the trumpet response sits lower and answers rather than duplicates.
while total_measures < target_phrase_measures:
call_phrase = rng.choice(phrases)
remaining_before_closing = target_phrase_measures - total_measures
call_transform = rng.choice(config.call_transforms)
call_variant = _make_variant(
call_phrase,
call_transform,
measure_units=measure_units,
phrase_measures=config.phrase_measures,
)
if call_variant.span_measures > remaining_before_closing:
call_variant = _make_variant(
call_phrase,
"identity",
measure_units=measure_units,
phrase_measures=min(config.phrase_measures, remaining_before_closing),
)
last_variant = call_variant
call_bins = [
_map_bin_to_instrument(
note_bin,
center_midi=74,
low=72,
high=91,
max_notes=1,
prefer_highest=True,
)
or None
for note_bin in call_variant.note_bins
]
whistle_bins.extend(call_bins)
trumpet_bins.extend([None] * len(call_bins))
total_measures += call_variant.span_measures
remaining_before_closing = target_phrase_measures - total_measures
if remaining_before_closing <= 0:
break
if rng.random() <= config.call_response_probability:
response_phrase = rng.choice(phrases)
response_variant = _make_variant(
response_phrase,
rng.choice(config.response_transforms),
measure_units=measure_units,
phrase_measures=config.phrase_measures,
)
if response_variant.span_measures > remaining_before_closing:
response_variant = _make_variant(
response_phrase,
"identity",
measure_units=measure_units,
phrase_measures=min(config.phrase_measures, remaining_before_closing),
)
last_variant = response_variant
response_bins = [
_map_bin_to_instrument(
note_bin,
center_midi=67,
low=55,
high=79,
max_notes=1,
prefer_highest=False,
)
or None
for note_bin in response_variant.note_bins
]
whistle_bins.extend([None] * len(response_bins))
trumpet_bins.extend(response_bins)
total_measures += response_variant.span_measures
The second excerpt shows how those generated bins become concrete instrument parts at the end of the movement build:
measures = config.total_measures
parts = (
InstrumentPart(
staff_id="violin",
name="Violin",
short_name="Vln.",
clef="treble",
midi_instrument="violin",
events=tuple(_phrase_bins_to_events(whistle_bins)),
),
InstrumentPart(
staff_id="trumpet",
name="Trumpet in C",
short_name="Tpt.",
clef="treble",
midi_instrument="trumpet",
events=tuple(_phrase_bins_to_events(trumpet_bins)),
),
InstrumentPart(
staff_id="percussion",
name="Percussion",
short_name="Perc.",
clef="percussion",
midi_instrument="woodblock",
events=tuple(_phrase_bins_to_events(percussion_bins)),
),
InstrumentPart(
staff_id="piano_rh",
name="Piano",
short_name="Pno.",
clef="treble",
midi_instrument="acoustic grand",
events=tuple(_phrase_bins_to_events(piano_rh_bins)),
),
InstrumentPart(
staff_id="piano_lh",
name="Piano",
short_name="Pno.",
clef="bass",
midi_instrument="acoustic grand",
events=tuple(_phrase_bins_to_events(piano_lh_bins)),
),
)
This is where the alternating bird effect comes from. One line states a phrase and the other line responds in the next span. Because calls and responses draw from overlapping but not identical transformation sets, the exchange can sound like imitation, transformation, or recollection. If violin or trumpet are unavailable in performance, the same lines could be reassigned to voice, whistle, or another treble instrument without changing the underlying phrase logic. The system is therefore instrument-specific in engraving, but not conceptually tied to one only possible ensemble.
Environmental Layers: Piano and Percussion#
The environmental effect in this piece is not produced by spectral fidelity alone. It comes from adding non-spectral layers that support the bird material without trying to mimic it exactly. The piano is the clearest example. The left hand provides the low drone and harmonic floor. The right hand adds a lighter, pattern-based comping layer on an eighth-note grid. Movement II is handled differently again, with a two-measure arpeggiated left-hand cycle to make the nocturne character more audible.
def _build_piano_bins(
config: MovementConfig,
*,
measures: int,
measure_units: int,
) -> tuple[list[tuple[int, ...] | None], list[tuple[int, ...] | None]]:
root = config.center_midi - 14
fifth = root + 7
octave = root + 12
shimmer_root = config.center_midi + 10
shimmer_fifth = shimmer_root + 7
shimmer_ninth = shimmer_root + 14
shimmer_upper = shimmer_root + 12
total_units = measures * measure_units
left_bins: list[tuple[int, ...] | None] = [None] * total_units
right_bins: list[tuple[int, ...] | None] = [None] * total_units
eighth_units = 2
half_measure = max(eighth_units, measure_units // 2)
if config.piano_pattern == "two-beat":
right_entries = (
(max(eighth_units, measure_units // 4), (shimmer_root, shimmer_fifth), 2),
(max(eighth_units, (measure_units * 3) // 4), (shimmer_root,), 2),
)
elif config.piano_pattern == "charleston":
right_entries = (
(0, (shimmer_root, shimmer_fifth), 3),
(max(eighth_units, (measure_units * 3) // 8), (shimmer_root,), 1),
)
elif config.piano_pattern == "anticipation":
right_entries = (
(max(eighth_units, measure_units - 2), (shimmer_fifth, shimmer_ninth), 2),
)
elif config.piano_pattern == "syncopated":
right_entries = (
(max(eighth_units, measure_units // 8), (shimmer_root,), 2),
(max(eighth_units, (measure_units * 3) // 8), (shimmer_fifth, shimmer_ninth), 2),
)
else:
raise ValueError(f"Unknown piano pattern: {config.piano_pattern}")
for measure_index in range(measures):
measure_start = measure_index * measure_units
in_closing = measure_index >= measures - config.closing_measures
if in_closing:
_apply_span(
left_bins,
start=measure_start,
duration=measure_units,
pitches=(root, fifth, octave),
)
_apply_span(
right_bins,
start=measure_start,
duration=half_measure,
pitches=(shimmer_root, shimmer_fifth),
)
_apply_span(
right_bins,
start=measure_start + half_measure,
duration=half_measure,
pitches=(shimmer_root,),
)
continue
if config.number == 2:
two_measure_phase = measure_index % 2
arpeggio_cycle = (
(0, (root,)),
(2, (fifth,)),
(4, (octave,)),
(6, (fifth,)),
(8, (root + 12,)),
(10, (fifth,)),
)
shifted_cycle = (
(0, (root, fifth)),
(2, (octave,)),
(4, (fifth,)),
(6, (root,)),
(8, (fifth, octave)),
(10, (fifth,)),
)
cycle = arpeggio_cycle if two_measure_phase == 0 else shifted_cycle
for offset, chord in cycle:
_apply_span(
left_bins,
start=measure_start + min(offset, max(0, measure_units - eighth_units)),
duration=eighth_units,
pitches=chord,
)
else:
if measure_index % 3 == 0:
left_chord = (root, fifth)
elif measure_index % 3 == 1:
left_chord = (root, octave)
else:
left_chord = (root, fifth, octave)
_apply_span(
left_bins,
start=measure_start,
duration=measure_units,
pitches=left_chord,
)
if measure_index % 2 == 1:
_apply_span(
left_bins,
start=measure_start + half_measure,
duration=half_measure,
pitches=(root, fifth),
)
shifted_entries = [
(
min(measure_units - eighth_units, start + (eighth_units if measure_index % 2 == 1 else 0)),
chord,
duration_eighths,
)
for start, chord, duration_eighths in right_entries
]
for start, chord, duration_eighths in shifted_entries:
_apply_span(
right_bins,
start=measure_start + start,
duration=max(eighth_units, duration_eighths * eighth_units),
pitches=chord,
)
return left_bins, right_bins
The percussion line is also environmental rather than imitative. It does not try to duplicate every attack in the birdsong. Instead, it uses named rhythmic patterns and then thins or accents them according to what the bird lines are doing. That gives the texture a sense of place: not literal forest noise, but a patterned pulse that can suggest environment, motion, or distance.
def _build_percussion_bins_from_pattern(
*,
whistle_bins: list[tuple[int, ...] | None],
trumpet_bins: list[tuple[int, ...] | None],
measures: int,
measure_units: int,
closing_measures: int,
pattern_name: str,
density: float,
rng: random.Random,
) -> list[tuple[int, ...] | None]:
bins: list[tuple[int, ...] | None] = []
cycle_units = measure_units * 2
pattern_hits = _scaled_pattern_hits(pattern_name, cycle_units)
previous_active = False
for index, (whistle_bin, trumpet_bin) in enumerate(zip(whistle_bins, trumpet_bins)):
measure_index = index // measure_units
unit_index = index % measure_units
cycle_index = index % cycle_units
active = whistle_bin is not None or trumpet_bin is not None
in_closing = measure_index >= measures - closing_measures
pattern_hit = cycle_index in pattern_hits and unit_index % 2 == 0
answer_hit = measure_index % 2 == 1 and unit_index == max(2, measure_units // 2)
entry_accent = active and not previous_active and rng.random() <= min(1.0, density + 0.15)
if in_closing:
bins.append((60,) if unit_index in (0, max(1, measure_units // 2)) else None)
elif pattern_hit and rng.random() <= min(1.0, density + 0.25):
bins.append((60,))
elif answer_hit and active and rng.random() <= density + 0.1:
bins.append((60,))
elif entry_accent:
bins.append((60,))
else:
bins.append(None)
previous_active = active
return bins
This added material is why the piece is not simply a spectral reduction with accompaniment. The ensemble code accepts that a playable composition can preserve the bird motive while also introducing supporting musical layers that are chosen for form, playability, and atmosphere.
How the Piece Is Rendered#
The package also treats rendering as part of the composition system. Notation is produced in LilyPond and then the audio path separates the ensemble into layers before synthesis. The first excerpt shows the layered render for one movement. It uses Salamander for the two piano hands, Aegean for the melodic ensemble, and a clap-style render for percussion.
def render_layered_wav_for_midi(
midi_path: str,
wav_path: str,
piano_soundfont_path: str,
ensemble_soundfont_path: str,
sample_rate: int,
) -> None:
midi = mido.MidiFile(midi_path)
piano_rh_channels = _channels_for_prefixes(midi, {"piano_rh"})
piano_lh_channels = _channels_for_prefixes(midi, {"piano_lh"})
ensemble_channels = _channels_for_prefixes(midi, {"violin", "trumpet"})
percussion_channels = _channels_for_prefixes(midi, {"percussion"})
if not piano_rh_channels or not piano_lh_channels or not ensemble_channels:
render_wav(midi_path, wav_path, ensemble_soundfont_path, sample_rate)
return
with tempfile.TemporaryDirectory() as temp_dir:
piano_rh_midi = Path(temp_dir) / "ensemble-piano-rh.midi"
piano_lh_midi = Path(temp_dir) / "ensemble-piano-lh.midi"
ensemble_midi = Path(temp_dir) / "ensemble-other.midi"
percussion_midi = Path(temp_dir) / "ensemble-percussion.midi"
piano_rh_wav = Path(temp_dir) / "ensemble-piano-rh.wav"
piano_lh_wav = Path(temp_dir) / "ensemble-piano-lh.wav"
ensemble_wav = Path(temp_dir) / "ensemble-other.wav"
percussion_wav = Path(temp_dir) / "ensemble-percussion.wav"
mixed_wav = Path(temp_dir) / "ensemble-mix.wav"
_write_filtered_midi(
midi,
piano_rh_channels,
piano_rh_midi,
channel_map={channel: 0 for channel in piano_rh_channels},
force_program=0,
)
_write_filtered_midi(
midi,
piano_lh_channels,
piano_lh_midi,
channel_map={channel: 0 for channel in piano_lh_channels},
force_program=0,
)
_write_filtered_midi(midi, ensemble_channels, ensemble_midi)
if percussion_channels:
_write_filtered_midi(midi, percussion_channels, percussion_midi)
render_wav(str(piano_rh_midi), str(piano_rh_wav), piano_soundfont_path, sample_rate, normalize_output=False)
render_wav(str(piano_lh_midi), str(piano_lh_wav), piano_soundfont_path, sample_rate, normalize_output=False)
render_wav(str(ensemble_midi), str(ensemble_wav), ensemble_soundfont_path, sample_rate, normalize_output=False)
layers = [str(piano_lh_wav), str(piano_rh_wav), str(ensemble_wav)]
weights = [1.8, 1.0, 0.9]
if percussion_channels:
render_clap_wav(str(percussion_midi), str(percussion_wav), sample_rate=sample_rate)
layers.append(str(percussion_wav))
weights.append(0.8)
mix_wavs(layers, str(mixed_wav), weights=weights)
normalize_wav(str(mixed_wav), wav_path, sample_rate)
The second excerpt shows how the movement MIDI files are ordered and concatenated into the full listening file:
def render_full_wav(
*,
output_dir: str,
stem: str,
piano_soundfont_path: str,
ensemble_soundfont_path: str,
sample_rate: int,
) -> None:
midi_paths = _movement_midi_paths(output_dir, stem)
if not midi_paths:
raise FileNotFoundError("No MIDI files found to render.")
movement_wavs: list[str] = []
for index, midi_path in enumerate(midi_paths, start=1):
movement_wav = os.path.join(output_dir, _movement_wav_name(stem, index))
render_layered_wav_for_midi(
midi_path=midi_path,
wav_path=movement_wav,
piano_soundfont_path=piano_soundfont_path,
ensemble_soundfont_path=ensemble_soundfont_path,
sample_rate=sample_rate,
)
movement_wavs.append(movement_wav)
with tempfile.TemporaryDirectory() as temp_dir:
concat_list = os.path.join(temp_dir, "concat.txt")
raw_output_wav = os.path.join(temp_dir, f"{stem}-raw.wav")
Path(concat_list).write_text(
"".join(f"file '{Path(path).resolve()}'\n" for path in movement_wavs),
encoding="utf-8",
)
output_wav = os.path.join(output_dir, f"{stem}.wav")
cmd = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_list,
"-c",
"copy",
raw_output_wav,
]
run_audio_command(cmd, wrote_path=raw_output_wav)
normalize_wav(raw_output_wav, output_wav, sample_rate)
This layered render path exists for musical reasons as much as technical ones. It keeps the piano drone audible, gives the treble material its own timbral space, and allows the release process to expose both the full piece and the individual movement WAVs. The build and release workflow therefore records not only the score but also the listening model used during composition. It now also preserves the appendix study movement renders so the transform demonstrations can be distributed alongside the main chamber movements.
What This Case Study Shows#
The ensemble package shows a practical way to move from analysis-derived material into a more independent composition. The source recording still plays an important role. The curated regions remain central as well. But the final result is not governed only by the source audio. It is governed by a phrase system, a movement plan, and a set of environmental layers designed to make the piece performable.
This hybrid approach is one way to move from analysis-derived material toward a more independent composition. It suggests how an analysis-driven motive can remain visible while the surrounding musical world becomes more flexible, more modular, and more explicitly composed.