e271da634e
Adds 274-lyrics-index.py and its output 274-lyrics-index.csv: every sung line resolved to its roster entry, with coordinates and timing. 98 lyric lines -> 99 rows. Columns are start/stop as mm:ss.xx and as seconds, the line as sung, the roster's full name for it, kind, km, lat/lon, and km_from/km_to for district headers. stop is the next timestamp in the file, spacer lines included, so every line has one. Four aliases cover the gap between sung and registered names: the three power stations are sung without their operator prefix, and モトツ橋にモトツシェルター names two facilities, so it emits two rows sharing one timing and one coordinate. 東に続く is typed as closing, no object. District headers resolve from the run of objects they introduce, not from 274-districts.json. Those bands are sampled along the road and so cannot describe a district the road does not 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 千栄. Object names in the roster are unique, so no other disambiguation is needed. The script exits non-zero and names any line it cannot resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
152 lines
5.8 KiB
Python
152 lines
5.8 KiB
Python
#!/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()
|