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-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)
### Path Generation
@ -115,6 +116,9 @@ python -m src.io --melodic-min 30 --melodic-max 200
# Use 4 voices with 4 dimensions
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
@ -124,7 +128,7 @@ The `legacy/` folder contains earlier versions of this code, used to create the
## 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_frequencies.txt` - Frequencies in Hz
- `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."""
file_path = Path(file_path)
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
graph_path = None

View file

@ -11,7 +11,14 @@ from random import seed
def write_chord_sequence(seq: list["Chord"], path: str) -> None:
"""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):
lines.append(" [")
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 " ],"
lines.append(chord_bracket)
lines.append(" ]")
lines.append("}")
with open(path, "w") as f:
f.write("\n".join(lines))
@ -325,6 +333,7 @@ def save_graph_to_cache(
def main():
"""Demo: Generate compact sets and build graph."""
import argparse
import ast
from .dims import DIMS_3, DIMS_4, DIMS_5, DIMS_7, DIMS_8
from .harmonic_space import HarmonicSpace
from .pathfinder import PathFinder
@ -419,6 +428,12 @@ def main():
default=7,
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("--max-path", type=int, default=50, help="Maximum path length")
parser.add_argument(
@ -532,19 +547,37 @@ def main():
sender.play()
return # Exit after OSC playback
# Select dims
if args.dims == 3:
dims = DIMS_3
elif args.dims == 4:
dims = DIMS_4
elif args.dims == 5:
dims = DIMS_5
elif args.dims == 7:
dims = DIMS_7
elif args.dims == 8:
dims = DIMS_8
# Select dims - check for custom dims first, then fall back to predefined
if args.dims_custom:
try:
custom_dims = ast.literal_eval(args.dims_custom)
if isinstance(custom_dims, tuple):
dims = custom_dims
dims_key = "".join(str(d) for d in dims) # e.g., 235
else:
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)
print(f"Space: {space}")
@ -557,7 +590,7 @@ def main():
if not args.no_cache and not args.rebuild_cache:
graph, was_cached = load_graph_from_cache(
args.cache_dir,
args.dims,
dims_key,
args.chord_size,
args.symdiff_min,
args.symdiff_max,
@ -591,7 +624,7 @@ def main():
save_graph_to_cache(
graph,
args.cache_dir,
args.dims,
dims_key,
args.chord_size,
args.symdiff_min,
args.symdiff_max,
@ -684,7 +717,13 @@ def main():
chords_file = os.path.join(args.output_dir, "output_chords.json")
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
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):
"""Load chords from output_chords.json."""
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")
def send_chord(self, index):

View file

@ -345,10 +345,9 @@ def _is_adjacent(hs1: tuple, hs2: tuple) -> bool:
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."""
primes = [3, 5, 7, 11]
for i in range(1, 5):
for i in range(1, len(primes) + 1):
diff = current[i] - prev[i]
if diff == 1:
return primes[i - 1]
@ -358,7 +357,7 @@ def _compute_dim_diff(current: tuple, prev: tuple) -> int:
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]:
"""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
prev_chord: list of hs_arrays from previous chord
staying_voices: indices of voices that stay
primes: list of primes for dimensional calculation
Returns:
(ref, dim_diff) tuple
@ -377,7 +377,7 @@ def _find_ref_and_dim_diff(
for idx in staying_voices:
prev_hs = prev_chord[idx]
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))
if not adjacent:
@ -387,12 +387,15 @@ def _find_ref_and_dim_diff(
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.
Args:
pitch_idx: index of the current pitch in the chord
chord_pitches: list of hs_arrays for all pitches in the chord
primes: list of primes for dimensional calculation
Returns:
(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:
continue
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))
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]
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.
Args:
chords: List of chords from output_chords.json
fundamental: Fundamental frequency in Hz
chord_duration: Duration of each chord in beats
dims: Tuple of prime dimensions (optional, for computing dim_diff)
Returns:
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:
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])
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]
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]:
ref = -1
dim_diff = 0
else:
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]
@ -599,8 +609,14 @@ def transcribe(
"""
import shutil
if isinstance(chords[0][0], dict):
music_data = output_chords_to_music_data(chords, fundamental)
# Handle both old format (list of chords) and new format (dict with dims + chords)
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:
music_data = chords