Files
taiko-editor/tja_parser.py

294 lines
13 KiB
Python

import os
import re
import math
class Note:
def __init__(self, note_type, time_ms, measure_idx, beat_pos, duration_ms=0):
self.type = int(note_type) # 1=Don, 2=Ka, 3=BigDon, 4=BigKa, etc.
self.time = time_ms
self.measure = measure_idx
self.beat = beat_pos # Beat index within the measure (e.g. 0.0, 0.5, 1.0)
self.duration = duration_ms # For rolls/balloons
def __repr__(self):
return f"Note(type={self.type}, time={self.time:.1f}, m={self.measure}, b={self.beat:.2f})"
class Event:
def __init__(self, event_type, value, time_ms, measure_idx, beat_pos):
self.type = event_type # 'bpm', 'scroll', 'measure', 'barline'
self.value = value
self.time = time_ms
self.measure = measure_idx
self.beat = beat_pos
class Course:
def __init__(self, difficulty):
self.difficulty = difficulty
self.level = 0
self.balloon = []
self.score_init = 0
self.score_diff = 0
self.notes = []
self.events = []
# Raw lines for reference or partial parsing
self.lines = []
class TJA:
def __init__(self, file_path=None):
self.headers = {
'TITLE': 'New Song',
'SUBTITLE': '',
'BPM': 120.0,
'WAVE': 'song.ogg',
'OFFSET': 0.0,
'DEMOSTART': 0.0,
'SONGVOL': 100,
'SEVOL': 100
}
self.courses = {} # 'Easy', 'Normal', 'Hard', 'Oni', 'Edit'
self.common_lines = [] # Lines before any COURSE: command
if file_path and os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except UnicodeDecodeError:
with open(file_path, 'r', encoding='shift_jis', errors='replace') as f:
content = f.read()
self.parse(content)
else:
# Initialize default courses
for diff in ['Easy', 'Normal', 'Hard', 'Oni', 'Edit']:
self.courses[diff] = Course(diff)
def parse(self, text):
lines = text.splitlines()
current_course_obj = None
current_lines = self.common_lines
# First pass: Separate lines by COURSE
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith('COURSE:'):
course_name = line.split(':', 1)[1].strip()
# Normalize course name
c_map = {'0': 'Easy', 'Easy': 'Easy', '1': 'Normal', 'Normal': 'Normal',
'2': 'Hard', 'Hard': 'Hard', '3': 'Oni', 'Oni': 'Oni',
'4': 'Edit', 'Edit': 'Edit'}
c_name = c_map.get(course_name, course_name)
if c_name not in self.courses:
self.courses[c_name] = Course(c_name)
current_course_obj = self.courses[c_name]
current_lines = current_course_obj.lines
elif line.startswith('#START'):
# Start of chart data, but we just store lines for now
if current_course_obj:
current_lines.append(line)
elif ':' in line and not line.startswith('#'):
# Header
key, val = line.split(':', 1)
key = key.strip().upper()
val = val.strip()
if current_course_obj is None:
# Global header
if key in ['TITLE', 'SUBTITLE', 'WAVE']:
self.headers[key] = val
elif key in ['BPM', 'OFFSET', 'DEMOSTART', 'SONGVOL', 'SEVOL']:
try:
self.headers[key] = float(val)
except:
pass
else:
# Course specific header
if key == 'LEVEL':
try: current_course_obj.level = int(val)
except: pass
elif key == 'BALLOON':
current_course_obj.balloon = [int(x) for x in val.split(',') if x.strip()]
elif key == 'SCOREINIT':
current_course_obj.score_init = int(val.split(',')[0])
elif key == 'SCOREDIFF':
current_course_obj.score_diff = int(val)
else:
current_lines.append(line)
# Second pass: Process notes for each course
base_bpm = self.headers['BPM']
base_offset = self.headers['OFFSET']
for course_name, course in self.courses.items():
self._parse_course_notes(course, base_bpm, base_offset)
def _parse_course_notes(self, course, bpm, offset):
course.notes = []
course.events = []
current_bpm = bpm
current_measure_frac = 4.0 / 4.0 # 4/4 time
# Calculate start time based on OFFSET
# TJA OFFSET: "A negative value makes the song start earlier (notes appear later)"
# Wait, TJA spec says: OFFSET is the time (in seconds) that the music starts relative to the first beat.
# If OFFSET is -1.9, music starts 1.9s BEFORE the first beat.
# So first beat is at +1.9s relative to music start.
# Current Time Pointer
current_time = -offset * 1000 # Convert to ms
# We need to process lines sequentially
# Notes are comma-separated.
# Commands start with #
measure_buffer = ""
measure_idx = 0
for line in course.lines:
line = line.split('//')[0].strip() # Remove comments
if not line: continue
if line.startswith('#START'):
current_time = -offset * 1000
measure_idx = 0
continue
if line.startswith('#END'):
break
if line.startswith('#'):
# Command
if line.startswith('#BPMCHANGE'):
try:
val = float(line.split()[1])
course.events.append(Event('bpm', val, current_time, measure_idx, 0))
current_bpm = val
except: pass
elif line.startswith('#MEASURE'):
try:
m_str = line.split()[1]
num, den = map(int, m_str.split('/'))
current_measure_frac = num / den
course.events.append(Event('measure', current_measure_frac, current_time, measure_idx, 0))
except: pass
elif line.startswith('#SCROLL'):
try:
val = float(line.split()[1])
course.events.append(Event('scroll', val, current_time, measure_idx, 0))
except: pass
elif line.startswith('#GOGOSTART'):
course.events.append(Event('gogo', True, current_time, measure_idx, 0))
elif line.startswith('#GOGOEND'):
course.events.append(Event('gogo', False, current_time, measure_idx, 0))
elif line.startswith('#BARLINEOFF'):
course.events.append(Event('barline', False, current_time, measure_idx, 0))
elif line.startswith('#BARLINEON'):
course.events.append(Event('barline', True, current_time, measure_idx, 0))
continue
# Accumulate measure string
measure_buffer += line
if measure_buffer.endswith(','):
# Process measure
notes_str = measure_buffer[:-1] # Remove comma
measure_buffer = ""
# Calculate duration of this measure
# beats per measure = 4 * current_measure_frac (e.g. 4/4 -> 4 beats, 3/4 -> 3 beats)
beats_in_measure = 4 * current_measure_frac
measure_duration = (60000 / current_bpm) * beats_in_measure
num_notes = len(notes_str)
if num_notes > 0:
time_per_char = measure_duration / num_notes
beat_per_char = beats_in_measure / num_notes
for i, char in enumerate(notes_str):
if char == '0': continue
if char in '1234': # Don, Ka, BigDon, BigKa
note_time = current_time + (i * time_per_char)
note_beat = i * beat_per_char
course.notes.append(Note(char, note_time, measure_idx, note_beat))
# Note: Logic for rolls (5,6,7,8) is more complex (start/end), simplified here for MVP
elif char in '5679': # Rolls/Balloon Start
note_time = current_time + (i * time_per_char)
note_beat = i * beat_per_char
course.notes.append(Note(char, note_time, measure_idx, note_beat))
elif char == '8': # End roll
note_time = current_time + (i * time_per_char)
note_beat = i * beat_per_char
course.notes.append(Note(char, note_time, measure_idx, note_beat))
current_time += measure_duration
measure_idx += 1
def save(self, file_path):
# Basic reconstruction logic
with open(file_path, 'w', encoding='utf-8') as f:
# Write Headers
for k, v in self.headers.items():
if v:
f.write(f"{k}:{v}\n")
f.write("\n")
# Write Courses
for name, course in self.courses.items():
if not course.notes and not course.events:
continue # Skip empty courses
f.write(f"COURSE:{name}\n")
f.write(f"LEVEL:{course.level}\n")
if course.balloon:
f.write(f"BALLOON:{','.join(map(str, course.balloon))}\n")
f.write(f"SCOREINIT:{course.score_init}\n")
f.write(f"SCOREDIFF:{course.score_diff}\n")
f.write("\n#START\n")
# Reconstruct measures from notes
# This is the hard part: "Export" logic
# For MVP, if we haven't edited the structure deeply, we could just dump the parsed events?
# But the prompt implies editing.
# Let's try a simple generation:
# Group notes by measure index.
max_measure = 0
if course.notes:
max_measure = max(n.measure for n in course.notes)
if course.events:
max_measure = max(max_measure, max(e.measure for e in course.events))
current_bpm = self.headers['BPM']
for m in range(max_measure + 1):
# Find events in this measure
m_events = [e for e in course.events if e.measure == m]
for e in m_events:
if e.type == 'bpm': f.write(f"#BPMCHANGE {e.value}\n")
if e.type == 'measure': f.write(f"#MEASURE {int(e.value*4)}/4\n") # Simplified
if e.type == 'gogo': f.write("#GOGOSTART\n" if e.value else "#GOGOEND\n")
# Construct note string
# Default to 16 divisions per measure (standard) or higher if needed
# Find notes in this measure
m_notes = sorted([n for n in course.notes if n.measure == m], key=lambda x: x.beat)
# Determine required resolution
# Simple approach: Use 16 chars per measure (4 chars per beat)
grid_size = 16
line = ['0'] * grid_size
for n in m_notes:
# map beat (0..4) to index (0..16)
# beat is 0.0 to 4.0 (in 4/4)
# index = (beat / 4.0) * 16 = beat * 4
idx = int(round(n.beat * (grid_size / 4.0))) # Assuming 4/4
if 0 <= idx < grid_size:
line[idx] = str(n.type)
f.write("".join(line) + ",\n")
f.write("#END\n\n")