diff --git a/README.md b/README.md index 01e571c..ba4795b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/analyze.py b/src/analyze.py index 1ae953b..62df6a8 100644 --- a/src/analyze.py +++ b/src/analyze.py @@ -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 diff --git a/src/io.py b/src/io.py index ece8216..980583d 100644 --- a/src/io.py +++ b/src/io.py @@ -11,9 +11,16 @@ 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(" [") + lines.append(" [") for pitch_idx, pitch in enumerate(chord._pitches): pitch_obj = { "hs_array": list(pitch.hs_array), @@ -22,10 +29,11 @@ def write_chord_sequence(seq: list["Chord"], path: str) -> None: } pitch_json = json.dumps(pitch_obj) comma = "," if pitch_idx < len(chord._pitches) - 1 else "" - lines.append(f" {pitch_json}{comma}") - chord_bracket = " ]" if chord_idx == len(seq) - 1 else " ]," + lines.append(f" {pitch_json}{comma}") + chord_bracket = " ]" if chord_idx == len(seq) - 1 else " ]," lines.append(chord_bracket) - lines.append("]") + 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: + # 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") diff --git a/src/osc_sender.py b/src/osc_sender.py index a87c898..214245d 100644 --- a/src/osc_sender.py +++ b/src/osc_sender.py @@ -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): diff --git a/src/transcriber.py b/src/transcriber.py index e6d9e8e..4af7128 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -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