"""TJA emission for slot-mode charts: slot indices ARE the chart. No quantization happens here — generate_song_slot() already produced exact lattice positions (measure, slot in 0..95), so this writer just prints them. Preview rendering and the .tja are identical by construction. Piecewise grids additionally emit #BPMCHANGE lines between measures. """ import numpy as np from .vocab import SLOTS CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4", "roll": "5", "roll_big": "6", "balloon": "7"} def grid_measure_starts(grid, n_measures): """Return measure boundary times for a slot grid, extending if needed.""" db = np.asarray(grid.get("downbeats", []), dtype=float) if len(db) >= n_measures + 1: return db[: n_measures + 1] if len(db) >= 2: step = float(np.median(np.diff(db))) start = float(db[0]) else: step = 240.0 / float(grid["bpm"]) start = float(db[0]) if len(db) else 0.0 return start + np.arange(n_measures + 1, dtype=float) * step def gogo_measure_mask(plan, measure_starts, n_measures): """Map plan climax blocks (flag == 2) to TJA measures.""" mask = [False] * n_measures if not plan: return mask starts = np.asarray(measure_starts, dtype=float) if len(starts) < n_measures + 1: return mask for block in plan: if len(block) < 4 or int(block[3]) != 2: continue a, b = float(block[0]), float(block[1]) if b <= a: continue for m in range(n_measures): if starts[m] < b and starts[m + 1] > a: mask[m] = True return mask def append_measure_with_gogo(lines, measure_line, measure_idx, gogo_mask, in_gogo): """Append a measure line, opening/closing #GOGO commands at boundaries.""" want_gogo = bool(gogo_mask[measure_idx]) if measure_idx < len(gogo_mask) else False if in_gogo and not want_gogo: lines.append("#GOGOEND") in_gogo = False if want_gogo and not in_gogo: lines.append("#GOGOSTART") in_gogo = True lines.append(measure_line) return in_gogo def write_tja_slots(gen, grid, title, course, level, wave, out_path=None, balloon_count=10, plan=None): """gen: generate_song_slot() result; grid: fit_grid() result (or a dict with downbeats+bpm). Returns the TJA text (and writes it if out_path).""" slots = {} for me, sl, cls in gen["hits_slots"]: slots.setdefault((me, sl), CHAR[cls]) for m0, s0, m1, s1, typ in gen.get("spans_slots", []): a = (m0, s0) while a in slots: # span start yields to hits: next free slot a = (a[0] + (a[1] + 1) // SLOTS, (a[1] + 1) % SLOTS) b = (m1, s1) while b in slots or b <= a: b = (b[0] + (b[1] + 1) // SLOTS, (b[1] + 1) % SLOTS) slots[a] = CHAR[typ] slots[b] = "8" n_meas = max(gen.get("n_measures", 0), (max(m for m, _ in slots) + 1) if slots else 1) # piecewise-tempo grids: emit #BPMCHANGE whenever the per-measure BPM # (from consecutive fitted barlines) moves; TJA measure lines are unchanged db = np.asarray(grid.get("downbeats", []), float) if grid.get("piecewise") else None measure_starts = grid_measure_starts(grid, n_meas) gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas) lines = [] cur_bpm = float(grid["bpm"]) in_gogo = False for m in range(n_meas): if in_gogo and not gogo_mask[m]: lines.append("#GOGOEND") in_gogo = False if db is not None and m + 1 < len(db): bpm_m = 240.0 / (db[m + 1] - db[m]) if abs(bpm_m - round(bpm_m)) < 0.05: bpm_m = float(round(bpm_m)) if abs(bpm_m - cur_bpm) > 0.05: lines.append(f"#BPMCHANGE {bpm_m:g}") cur_bpm = bpm_m in_gogo = append_measure_with_gogo( lines, "".join(slots.get((m, k), "0") for k in range(SLOTS)) + ",", m, gogo_mask, in_gogo) if in_gogo: lines.append("#GOGOEND") balloons = [balloon_count] * sum(1 for s in gen.get("spans_slots", []) if s[4] == "balloon") offset = float(grid["downbeats"][0]) if len(grid.get("downbeats", [])) else 0.0 tja = "\n".join([ f"TITLE:{title} (SoftChart)", f"BPM:{grid['bpm']:g}", f"WAVE:{wave}", f"OFFSET:{-offset:.3f}", f"COURSE:{'Oni' if course == 'oni' else str(course).capitalize()}", f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:", "", "#START", *lines, "#END"]) + "\n" if out_path: with open(out_path, "w") as f: f.write(tja) return tja