Files
ncmr-songs/wip/274/274-lyrics-index.py
T
mikkeli e271da634e 274: lyric index for the visualiser
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>
2026-09-16 18:28:37 +09:00

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()