#!/usr/bin/env python3 """Build the lyric index: every sung line resolved to its roster entry and timing. Reads 274.lrc and 274-lookup.csv and writes 274-lyrics-index.csv, one row per resolved object per lyric line, in song order. start / stop the line's own timestamp and the next timestamp in the file, blank spacer lines included — so every line has a stop. sung the text exactly as it appears in the lyric object the roster's full name for it (the power stations carry their operator prefix there but are sung without it) kind, lat, lon from the roster Two lines need more than a name lookup: * モトツ橋にモトツシェルター is one structure driven through and two facilities in the registers, so it emits TWO rows sharing one timing. * The district headers (勇払郡むかわ町穂別福山 and the rest) are addresses, not points, and three of them repeat because the road re-enters the district — so they cannot resolve by name alone. Each is resolved from the run of objects it introduces: the header takes the first object's coordinates, and km_from / km_to span the run. Not from 274-districts.json, which is sampled along the road and therefore cannot describe a district the road does not physically enter. 宮下町 has no band at all, 本町東 exists only as 本町東一丁目, and 千栄's band sits at 107.25-107.75 while the station it introduces is at km 104.52, 666 m off the carriageway inside 千栄. python3 274-lyrics-index.py """ import csv import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) p = lambda n: os.path.join(HERE, n) # lyric text -> roster object name(s). The sung form drops operator prefixes, # and one line names two facilities. ALIAS = { '川端発電所': ['北海道企業局 川端発電所'], '滝の上発電所': ['北海道企業局 滝の上発電所'], '北電日高発電所': ['北海道電力 日高発電所'], 'モトツ橋にモトツシェルター': ['モトツ橋', 'モトツシェルター'], } # sung lines that are deliberately not objects NON_OBJECT = {'東に続く'} TS = re.compile(r'^\[(\d+):(\d+(?:\.\d+)?)\](.*)$') def mmss(t): return '%02d:%05.2f' % (int(t // 60), t % 60) def read_lrc(path): out = [] for line in io.open(path, encoding='utf-8'): m = TS.match(line.rstrip('\n')) if m: out.append((int(m.group(1)) * 60 + float(m.group(2)), m.group(3).strip())) return out def main(): rows = list(csv.DictReader(io.open(p('274-lookup.csv'), encoding='utf-8'))) by_obj = {r['object']: r for r in rows} addresses = {r['address'] for r in rows} lrc = read_lrc(p('274.lrc')) texts = [(i, t, s) for i, (t, s) in enumerate(lrc) if s] def targets(sung): if sung in ALIAS: return [by_obj[n] for n in ALIAS[sung] if n in by_obj] if sung in by_obj: return [by_obj[sung]] return [] def run_after(i, sung): """The objects a district header introduces, up to the next header.""" got = [] for j, _t, s in texts: if j <= i: continue if s in addresses and not targets(s): break got.extend(targets(s)) return [r for r in got if r['address'] == sung] or got out, unresolved = [], [] for i, t, sung in texts: stop = lrc[i + 1][0] if i + 1 < len(lrc) else None got = targets(sung) if got: for r in got: out.append(dict( start=t, stop=stop, sung=sung, object=r['object'], kind=r['kind'], km=float(r['km']), lat=float(r['lat']), lon=float(r['lon']), km_from='', km_to='')) continue # district header: resolve from the run of objects it introduces run = run_after(i, sung) if sung in addresses else [] if run: kms = [float(r['km']) for r in run] first = run[0] out.append(dict( start=t, stop=stop, sung=sung, object=sung, kind='district', km=float(first['km']), lat=float(first['lat']), lon=float(first['lon']), km_from='%.2f' % min(kms), km_to='%.2f' % max(kms))) elif sung in NON_OBJECT: out.append(dict(start=t, stop=stop, sung=sung, object='', kind='closing', km='', lat='', lon='', km_from='', km_to='')) else: unresolved.append((t, sung)) cols = ['start', 'stop', 'start_s', 'stop_s', 'sung', 'object', 'kind', 'km', 'lat', 'lon', 'km_from', 'km_to'] with io.open(p('274-lyrics-index.csv'), 'w', encoding='utf-8', newline='') as f: w = csv.writer(f) w.writerow(cols) for r in out: w.writerow([ mmss(r['start']), mmss(r['stop']) if r['stop'] is not None else '', '%.2f' % r['start'], '%.2f' % r['stop'] if r['stop'] is not None else '', r['sung'], r['object'], r['kind'], ('%.2f' % r['km']) if r['km'] != '' else '', ('%.5f' % r['lat']) if r['lat'] != '' else '', ('%.5f' % r['lon']) if r['lon'] != '' else '', r['km_from'], r['km_to']]) kinds = {} for r in out: kinds[r['kind']] = kinds.get(r['kind'], 0) + 1 print('%d lyric lines -> %d index rows' % (len(texts), len(out))) print(' ' + ', '.join('%s %d' % kv for kv in sorted(kinds.items()))) if unresolved: print('UNRESOLVED:', file=sys.stderr) for t, s in unresolved: print(' %s %s' % (mmss(t), s), file=sys.stderr) sys.exit(1) if __name__ == '__main__': main()