Add custom dims support via --dims-custom and update JSON output format

This commit is contained in:
Michael Winter 2026-03-28 15:12:14 +01:00
parent bb7a9ccb21
commit 1dff1022ce
5 changed files with 98 additions and 26 deletions

View file

@ -18,7 +18,8 @@ python -m src.io --dims 4 --chord-size 3
--symdiff-min N Minimum symmetric difference between chords (default: 2) --symdiff-min N Minimum symmetric difference between chords (default: 2)
--symdiff-max N Maximum symmetric difference between chords (default: 2) --symdiff-max N Maximum symmetric difference between chords (default: 2)
--dims N Number of prime dimensions: 4, 5, 7, or 8 (default: 7) --dims N Number of prime dimensions: 3, 4, 5, 7, or 8 (default: 7)
--dims-custom "()", Custom dims as tuple, e.g., "(2,3,7)" (overrides --dims)
--chord-size N Number of voices per chord (default: 3) --chord-size N Number of voices per chord (default: 3)
### Path Generation ### Path Generation
@ -115,6 +116,9 @@ python -m src.io --melodic-min 30 --melodic-max 200
# Use 4 voices with 4 dimensions # Use 4 voices with 4 dimensions
python -m src.io --dims 4 --chord-size 4 python -m src.io --dims 4 --chord-size 4
# Use custom dims (e.g., exclude prime 5)
python -m src.io --dims-custom "(2,3,7)" --chord-size 4
``` ```
## Legacy ## Legacy
@ -124,7 +128,7 @@ The `legacy/` folder contains earlier versions of this code, used to create the
## Output ## Output
Generated files go to `output/`: Generated files go to `output/`:
- `output_chords.json` - Chord data - `output_chords.json` - Chord data (includes dims and chords)
- `output_chords.txt` - Human-readable chords - `output_chords.txt` - Human-readable chords
- `output_frequencies.txt` - Frequencies in Hz - `output_frequencies.txt` - Frequencies in Hz
- `graph_path.json` - Hashes of graph nodes visited (for DCA Hamiltonian analysis) - `graph_path.json` - Hashes of graph nodes visited (for DCA Hamiltonian analysis)

View file

@ -224,7 +224,13 @@ def analyze_file(file_path: str | Path, config: dict | None = None) -> dict:
"""Load and analyze a chord file.""" """Load and analyze a chord file."""
file_path = Path(file_path) file_path = Path(file_path)
with open(file_path) as f: with open(file_path) as f:
chords = json.load(f) chords_data = json.load(f)
# Handle both old format (list) and new format (dict with dims + chords)
if isinstance(chords_data, dict) and "chords" in chords_data:
chords = chords_data["chords"]
else:
chords = chords_data
# Try to load graph_path if it exists # Try to load graph_path if it exists
graph_path = None graph_path = None

View file

@ -11,7 +11,14 @@ from random import seed
def write_chord_sequence(seq: list["Chord"], path: str) -> None: def write_chord_sequence(seq: list["Chord"], path: str) -> None:
"""Write a chord sequence to a JSON file.""" """Write a chord sequence to a JSON file."""
lines = ["["] if not seq:
return
dims = list(seq[0].dims)
lines = ["{"]
lines.append(f' "dims": {json.dumps(dims)},')
lines.append(' "chords": [')
for chord_idx, chord in enumerate(seq): for chord_idx, chord in enumerate(seq):
lines.append(" [") lines.append(" [")
for pitch_idx, pitch in enumerate(chord._pitches): for pitch_idx, pitch in enumerate(chord._pitches):
@ -26,6 +33,7 @@ def write_chord_sequence(seq: list["Chord"], path: str) -> None:
chord_bracket = " ]" if chord_idx == len(seq) - 1 else " ]," chord_bracket = " ]" if chord_idx == len(seq) - 1 else " ],"
lines.append(chord_bracket) lines.append(chord_bracket)
lines.append(" ]") lines.append(" ]")
lines.append("}")
with open(path, "w") as f: with open(path, "w") as f:
f.write("\n".join(lines)) f.write("\n".join(lines))
@ -325,6 +333,7 @@ def save_graph_to_cache(
def main(): def main():
"""Demo: Generate compact sets and build graph.""" """Demo: Generate compact sets and build graph."""
import argparse import argparse
import ast
from .dims import DIMS_3, DIMS_4, DIMS_5, DIMS_7, DIMS_8 from .dims import DIMS_3, DIMS_4, DIMS_5, DIMS_7, DIMS_8
from .harmonic_space import HarmonicSpace from .harmonic_space import HarmonicSpace
from .pathfinder import PathFinder from .pathfinder import PathFinder
@ -419,6 +428,12 @@ def main():
default=7, default=7,
help="Number of prime dimensions (3, 4, 5, 7, or 8)", help="Number of prime dimensions (3, 4, 5, 7, or 8)",
) )
parser.add_argument(
"--dims-custom",
type=str,
default=None,
help="Custom dims as tuple string, e.g., '(2,3,7)' (overrides --dims)",
)
parser.add_argument("--chord-size", type=int, default=3, help="Size of chords") parser.add_argument("--chord-size", type=int, default=3, help="Size of chords")
parser.add_argument("--max-path", type=int, default=50, help="Maximum path length") parser.add_argument("--max-path", type=int, default=50, help="Maximum path length")
parser.add_argument( parser.add_argument(
@ -532,19 +547,37 @@ def main():
sender.play() sender.play()
return # Exit after OSC playback return # Exit after OSC playback
# Select dims # Select dims - check for custom dims first, then fall back to predefined
if args.dims == 3: if args.dims_custom:
dims = DIMS_3 try:
elif args.dims == 4: custom_dims = ast.literal_eval(args.dims_custom)
dims = DIMS_4 if isinstance(custom_dims, tuple):
elif args.dims == 5: dims = custom_dims
dims = DIMS_5 dims_key = "".join(str(d) for d in dims) # e.g., 235
elif args.dims == 7:
dims = DIMS_7
elif args.dims == 8:
dims = DIMS_8
else: else:
dims = DIMS_7 dims = DIMS_7
dims_key = 7
except (ValueError, SyntaxError):
dims = DIMS_7
dims_key = 7
elif args.dims == 3:
dims = DIMS_3
dims_key = 3
elif args.dims == 4:
dims = DIMS_4
dims_key = 4
elif args.dims == 5:
dims = DIMS_5
dims_key = 5
elif args.dims == 7:
dims = DIMS_7
dims_key = 7
elif args.dims == 8:
dims = DIMS_8
dims_key = 8
else:
dims = DIMS_7
dims_key = 7
space = HarmonicSpace(dims, collapsed=True) space = HarmonicSpace(dims, collapsed=True)
print(f"Space: {space}") print(f"Space: {space}")
@ -557,7 +590,7 @@ def main():
if not args.no_cache and not args.rebuild_cache: if not args.no_cache and not args.rebuild_cache:
graph, was_cached = load_graph_from_cache( graph, was_cached = load_graph_from_cache(
args.cache_dir, args.cache_dir,
args.dims, dims_key,
args.chord_size, args.chord_size,
args.symdiff_min, args.symdiff_min,
args.symdiff_max, args.symdiff_max,
@ -591,7 +624,7 @@ def main():
save_graph_to_cache( save_graph_to_cache(
graph, graph,
args.cache_dir, args.cache_dir,
args.dims, dims_key,
args.chord_size, args.chord_size,
args.symdiff_min, args.symdiff_min,
args.symdiff_max, args.symdiff_max,
@ -684,7 +717,13 @@ def main():
chords_file = os.path.join(args.output_dir, "output_chords.json") chords_file = os.path.join(args.output_dir, "output_chords.json")
with open(chords_file) as f: with open(chords_file) as f:
chords = json.load(f) chords_data = json.load(f)
# Handle both old format (list) and new format (dict with dims + chords)
if isinstance(chords_data, dict) and "chords" in chords_data:
chords = chords_data["chords"]
else:
chords = chords_data
# Load graph_path for Hamiltonian analysis # Load graph_path for Hamiltonian analysis
graph_path_file = os.path.join(args.output_dir, "graph_path.json") graph_path_file = os.path.join(args.output_dir, "graph_path.json")

View file

@ -30,7 +30,14 @@ class OSCSender:
def load_chords(self, chords_file): def load_chords(self, chords_file):
"""Load chords from output_chords.json.""" """Load chords from output_chords.json."""
with open(chords_file) as f: with open(chords_file) as f:
self.chords = json.load(f) chords_data = json.load(f)
# Handle both old format (list) and new format (dict with dims + chords)
if isinstance(chords_data, dict) and "chords" in chords_data:
self.chords = chords_data["chords"]
else:
self.chords = chords_data
print(f"Loaded {len(self.chords)} chords") print(f"Loaded {len(self.chords)} chords")
def send_chord(self, index): def send_chord(self, index):

View file

@ -345,10 +345,9 @@ def _is_adjacent(hs1: tuple, hs2: tuple) -> bool:
return diff_count == 1 return diff_count == 1
def _compute_dim_diff(current: tuple, prev: tuple) -> int: def _compute_dim_diff(current: tuple, prev: tuple, primes: list[int]) -> int:
"""Compute dim_diff between two hs_arrays. Returns prime * direction.""" """Compute dim_diff between two hs_arrays. Returns prime * direction."""
primes = [3, 5, 7, 11] for i in range(1, len(primes) + 1):
for i in range(1, 5):
diff = current[i] - prev[i] diff = current[i] - prev[i]
if diff == 1: if diff == 1:
return primes[i - 1] return primes[i - 1]
@ -358,7 +357,7 @@ def _compute_dim_diff(current: tuple, prev: tuple) -> int:
def _find_ref_and_dim_diff( def _find_ref_and_dim_diff(
current_hs: tuple, prev_chord: list, staying_voices: list current_hs: tuple, prev_chord: list, staying_voices: list, primes: list[int]
) -> tuple[int, int]: ) -> tuple[int, int]:
"""Find ref (staying voice index) and dim_diff for a changed pitch. """Find ref (staying voice index) and dim_diff for a changed pitch.
@ -366,6 +365,7 @@ def _find_ref_and_dim_diff(
current_hs: hs_array of current pitch current_hs: hs_array of current pitch
prev_chord: list of hs_arrays from previous chord prev_chord: list of hs_arrays from previous chord
staying_voices: indices of voices that stay staying_voices: indices of voices that stay
primes: list of primes for dimensional calculation
Returns: Returns:
(ref, dim_diff) tuple (ref, dim_diff) tuple
@ -377,7 +377,7 @@ def _find_ref_and_dim_diff(
for idx in staying_voices: for idx in staying_voices:
prev_hs = prev_chord[idx] prev_hs = prev_chord[idx]
if _is_adjacent(current_hs, prev_hs): if _is_adjacent(current_hs, prev_hs):
dim_diff = _compute_dim_diff(current_hs, prev_hs) dim_diff = _compute_dim_diff(current_hs, prev_hs, primes)
adjacent.append((idx, dim_diff)) adjacent.append((idx, dim_diff))
if not adjacent: if not adjacent:
@ -387,12 +387,15 @@ def _find_ref_and_dim_diff(
return adjacent[0] return adjacent[0]
def _find_ref_in_same_chord(pitch_idx: int, chord_pitches: list) -> tuple[int, int]: def _find_ref_in_same_chord(
pitch_idx: int, chord_pitches: list, primes: list[int]
) -> tuple[int, int]:
"""Find ref (other pitch index) and dim_diff within the same chord. """Find ref (other pitch index) and dim_diff within the same chord.
Args: Args:
pitch_idx: index of the current pitch in the chord pitch_idx: index of the current pitch in the chord
chord_pitches: list of hs_arrays for all pitches in the chord chord_pitches: list of hs_arrays for all pitches in the chord
primes: list of primes for dimensional calculation
Returns: Returns:
(ref, dim_diff) tuple where ref is index of adjacent pitch in same chord (ref, dim_diff) tuple where ref is index of adjacent pitch in same chord
@ -404,7 +407,7 @@ def _find_ref_in_same_chord(pitch_idx: int, chord_pitches: list) -> tuple[int, i
if idx == pitch_idx: if idx == pitch_idx:
continue continue
if _is_adjacent(current_hs, other_hs): if _is_adjacent(current_hs, other_hs):
dim_diff = _compute_dim_diff(current_hs, other_hs) dim_diff = _compute_dim_diff(current_hs, other_hs, primes)
adjacent.append((idx, dim_diff)) adjacent.append((idx, dim_diff))
if not adjacent: if not adjacent:
@ -414,13 +417,14 @@ def _find_ref_in_same_chord(pitch_idx: int, chord_pitches: list) -> tuple[int, i
return adjacent[0] return adjacent[0]
def output_chords_to_music_data(chords, fundamental=55, chord_duration=4): def output_chords_to_music_data(chords, fundamental=55, chord_duration=4, dims=None):
"""Convert output_chords.json format to generic music data. """Convert output_chords.json format to generic music data.
Args: Args:
chords: List of chords from output_chords.json chords: List of chords from output_chords.json
fundamental: Fundamental frequency in Hz fundamental: Fundamental frequency in Hz
chord_duration: Duration of each chord in beats chord_duration: Duration of each chord in beats
dims: Tuple of prime dimensions (optional, for computing dim_diff)
Returns: Returns:
List of voices, each voice is a list of [freq, duration, ref, dim_diff] List of voices, each voice is a list of [freq, duration, ref, dim_diff]
@ -428,6 +432,12 @@ def output_chords_to_music_data(chords, fundamental=55, chord_duration=4):
if not chords: if not chords:
return [] return []
# Compute primes from dims (skip dimension 0 which is the fundamental)
if dims is not None:
primes = list(dims[1:]) # Skip first prime (2)
else:
primes = [3, 5, 7, 11] # Default fallback
num_voices = len(chords[0]) num_voices = len(chords[0])
music_data = [[] for _ in range(num_voices)] music_data = [[] for _ in range(num_voices)]
@ -452,13 +462,13 @@ def output_chords_to_music_data(chords, fundamental=55, chord_duration=4):
current_hs_array = current_hs[voice_idx] current_hs_array = current_hs[voice_idx]
if prev_chord is None: if prev_chord is None:
ref, dim_diff = _find_ref_in_same_chord(voice_idx, current_hs) ref, dim_diff = _find_ref_in_same_chord(voice_idx, current_hs, primes)
elif current_hs_array == prev_chord[voice_idx]: elif current_hs_array == prev_chord[voice_idx]:
ref = -1 ref = -1
dim_diff = 0 dim_diff = 0
else: else:
ref, dim_diff = _find_ref_and_dim_diff( ref, dim_diff = _find_ref_and_dim_diff(
current_hs_array, prev_chord, staying_voices current_hs_array, prev_chord, staying_voices, primes
) )
event = [freq, chord_duration, ref, dim_diff] event = [freq, chord_duration, ref, dim_diff]
@ -599,8 +609,14 @@ def transcribe(
""" """
import shutil import shutil
if isinstance(chords[0][0], dict): # Handle both old format (list of chords) and new format (dict with dims + chords)
music_data = output_chords_to_music_data(chords, fundamental) dims = None
if isinstance(chords, dict) and "chords" in chords:
dims = tuple(chords["dims"])
chords = chords["chords"]
if chords and isinstance(chords[0], list) and isinstance(chords[0][0], dict):
music_data = output_chords_to_music_data(chords, fundamental, dims=dims)
else: else:
music_data = chords music_data = chords