| name | sta-make |
| description | End-to-end STA (departure-melody + closing-door announcement) processing for a route — splitting source mp3s into per-segment files, trimming silences, validating sta_cut placement, and a by-ear verification gate. STA-only; for PA see pa-make. |
| triggers | ["/sta-make","/split-sta","/verify-sta","sta workflow","split sta","trim sta","sta_cut","verify sta","listen sta"] |
Purpose
Take STA recordings (raw or already split) and produce simulator-ready per-segment mp3s with sta_cut values that land cleanly in the music→voice silence gap. Each STA is one departure: melody (varies by station/platform) → silence pad → closing-door announcement (staff voice).
Two entry points — both are in scope:
- Phase A (first-time split): source mp3 + hand-written timestamps exist; cut into per-segment files + write route.json.
- Phase B (validation / refinement): per-segment files already exist; trim silences, validate
sta_cut, by-ear gate, fix any failures.
For PA (announcement) processing, see the pa-make skill — separate workflow with its own conventions (no melody, no sta_cut, different filenames).
When to run
- User points at
audio_src/<line>/<diagram>/ containing source mp3 + sta_timestamps.txt and asks to split → run Phase A → Phase B.
- User points at an existing
audio/<line>/<diagram>/ and asks to validate / verify / refine sta_cut → run Phase B only.
Required input
- Phase A: source folder path; line + diagram (target:
audio/<line>/<diagram>/); split scripts (if existing — ask before overwriting).
- Phase B: route folder path (
audio/<line>/<diagram>/).
Working files live under audio_src/
All mid-products of this workflow stay under audio_src/ (gitignored). The repo only ever ships the operational outputs (audio/<line>/<diagram>/sta/*.mp3 and route.json). Anything else — source mp3s, timestamps, splitter scripts, trim/splice backup snapshots, by-ear verifier results — is local-only.
If audio_src/<line>/<diagram>/ doesn't exist yet (common when revisiting an existing route for Phase B only), create it on demand. Per-line/diagram subfolders mirror the operational hierarchy 1:1.
audio_src/ # gitignored — local-only workspace
└── sobu/1217F/
├── sta_from_higashichiba.mp3 # STA source (may be partial coverage)
├── sta_timestamps.txt # STA timestamps
├── split_sta.py # generated by this skill
├── sta.bak/ # snapshot before trim/splice (Phase B Step 7)
├── route.json.bak # snapshot before trim/splice (Phase B Step 7)
└── sta_verify_results.json # by-ear verifier output (Phase B Step 11)
Defensive *.bak, *.bak/, _sta_verify_*.json globs are also gitignored so anything that escapes to project root or alongside operational audio doesn't accidentally get committed — but the convention is to put them in audio_src/ from the start.
Cross-PC note: because audio_src/ is gitignored, switching machines for further recuts requires manual cloud sync of the folder. If audio src work is rare, this is fine; if it becomes frequent, zip and stash.
Phase A — First-time split
Step 1 — Inspect
ls the source folder. Read sta_timestamps.txt if present. Identify which timestamp format applies (3-timestamp explicit-end vs 2-timestamp implicit-end — see Conventions). If split_sta*.py already exists, read it — the user may have customized. If there is no sta_timestamps.txt (just a sta_src.mp3 with multiple concatenated segments), continue to Step 1.5 to auto-detect boundaries.
Step 1.5 — Auto-detect segments (only when no sta_timestamps.txt)
When the source contains multiple stations' STA recordings concatenated end-to-end (typical structure: music → mid-gap silence → voice fragment → tiny voice-break → voice fragment → between-segment silence → next music...), segment boundaries can be auto-detected. Each segment is one [music | mid-gap | voice] unit.
Algorithm. Compute 50 ms RMS envelope. Find silence runs (< -42 dB, ≥ 0.5 s). Classify each silence by the duration of activity that follows it:
- Followed by > 6 s of activity (music block): between-segments boundary (segment END)
- Followed by 1.5–6 s of activity (voice fragment): mid-gap (music → voice cut point)
- Followed by < 1.5 s of activity: voice-break within voice (skip — typical closing-door announcements have a brief pause partway through)
Each segment runs from one between-silence's END (or file start) to the next between-silence's START (or EOF). The mid-gap silence's start = mid_cut.
import librosa, numpy as np
from pathlib import Path
SRC = Path("audio_src/<line>/<diagram>/sta_src.mp3")
y, sr = librosa.load(SRC, sr=22050, mono=True)
hop = int(sr * 0.05)
rms_db = 20 * np.log10(librosa.feature.rms(y=y, hop_length=hop)[0] + 1e-10)
times = np.arange(len(rms_db)) * 0.05
SIL_THRESH = -42
MIN_SIL = 0.5
in_sil = rms_db < SIL_THRESH
runs, start = [], None
for i, s in enumerate(in_sil):
if s and start is None: start = i
elif not s and start is not None:
runs.append((times[start], times[i-1])); start = None
if start is not None:
runs.append((times[start], times[-1]))
sils = [(s, e) for s, e in runs if (e-s) >= MIN_SIL]
EOF = len(y) / sr
segments, seg_start, mid_cut = [], sils[0][1], None
for i, (s, e) in enumerate(sils):
if i == 0: continue
next_active = (sils[i+1][0] if i+1 < len(sils) else EOF) - e
if next_active > 6:
segments.append((seg_start, mid_cut, s))
seg_start, mid_cut = e, None
elif next_active > 1.5:
mid_cut = s
if mid_cut is not None:
segments.append((seg_start, mid_cut, EOF))
for i, (s, c, e) in enumerate(segments, 1):
print(f"seg {i}: start={s:.2f} cut={c:.2f} end={e:.2f} music={c-s:.2f}s voice={e-c:.2f}s")
Surface to user before cutting (Step 2 discussion still applies):
- Segment count vs route's stop count. Off by 1 typically means the source includes a station this train doesn't stop at (e.g., a recently-opened station — keiyo's 幕張豊砂 case) — that segment goes to
_archive.
- Duration outliers. Music typically 7–15 s; voice 5–7 s. Music > 20 s may be an unusually elaborate melody (Tokyo-end of keiyo) OR two segments glued. Voice < 4 s is suspicious.
- Source order — first segment → last segment direction along the route. Don't assume.
Once user confirms mapping, generate split_sta.py using the auto-detected timestamps as SEGMENTS. Apply a 0.3 s pad on each side of each segment when cutting (silence boundaries are tight to music/voice edges; pad gives trim_sta_silence.py lead/trail silence to normalize). Bake the pad into sta_cut: sta_cut = mid_cut − (seg_start − PAD). Then continue to Step 4.
Step 2 — Parse + discuss BEFORE acting
Surface to the user before generating any script or touching route.json:
- Total segment count
- Platform mapping per station (
(N) notation), any song names provided
- Suspicious gaps — trailing-announcement section shorter than ~5 s is suspicious (closing-door announcements run 5–20 s normally)
- Multi-platform stations — if the same station has 2+ recordings, identify which the train actually uses; the rest go to
_archive
- Discussion-first preference: format variance between sources is common, surprises are normal — don't assume "the format" exists
Wait for user confirmation on the parse before generating.
Step 3 — Generate the splitter (per-source, ad-hoc)
Splitters are per-source artifacts, not a maintained library. Format varies between sources — one batch may use 3 timestamps per line (start/cut/end), another may use 2 (start/cut, end implicit). Each source gets its own script reflecting its own format. Don't try to unify into "the splitter".
Naming: split_sta.py if it's the only STA source in the folder; split_sta_<describer>.py if multiple (e.g., split_sta_tokyo.py + split_sta_higashichiba.py). Lives in the source folder so the audit trail of "how this batch was split" stays with the data.
STA splitter — explicit-end format (3 timestamps per line: start/cut/end). Add an op / archive dest tag per segment so re-runs route operational vs unused recordings to the right folder automatically:
"""Split sta source into per-segment files + archive routing."""
import subprocess, sys
from pathlib import Path
SRC = Path(__file__).parent / "sta_source.mp3"
PROJECT = Path(__file__).resolve().parents[3]
OUT_OP = PROJECT / "audio" / "<line>" / "<diagram>" / "sta"
OUT_ARCHIVE = PROJECT / "audio" / "_archive" / "<line>" / "<diagram>" / "sta"
SEGMENTS = [
("0:12", "1:08", "1:18", "tsuga_2_gota-del-vient", "op"),
("5:43", "6:08", "6:17", "narita_5_furawa-shoppu", "archive"),
]
def to_sec(ts):
m, s = map(int, ts.split(":"))
return m * 60 + s
for start, cut, end, name, dest in SEGMENTS:
out_dir = OUT_ARCHIVE if dest == "archive" else OUT_OP
out_dir.mkdir(parents=True, exist_ok=True)
start_sec = to_sec(start)
duration = to_sec(end) - start_sec
sta_cut_sec = to_sec(cut) - start_sec
cmd = ["ffmpeg", "-y", "-loglevel", "error",
"-ss", str(start_sec), "-i", str(SRC),
"-t", str(duration),
"-c", "copy", str(out_dir / f"{name}.mp3")]
print(f"{name}.mp3 [{dest}] start={start} cut={cut} end={end} sta_cut={sta_cut_sec}")
subprocess.run(cmd, check=True)
STA splitter — implicit-end format (2 timestamps per line: start/cut, end = next start, last = EOF):
EOF = "5:18"
SEGMENTS = [
("0:24", "0:40", "tokyo_4_jr-sh5-1", "op"),
("0:47", "1:07", "tokyo_3_twilight", "archive"),
]
for i, (start, cut, name, dest) in enumerate(SEGMENTS):
out_dir = OUT_ARCHIVE if dest == "archive" else OUT_OP
out_dir.mkdir(parents=True, exist_ok=True)
start_sec = to_sec(start)
end = SEGMENTS[i + 1][0] if i + 1 < len(SEGMENTS) else EOF
duration = to_sec(end) - start_sec
sta_cut_sec = to_sec(cut) - start_sec
cmd = ["ffmpeg", "-y", "-loglevel", "error",
"-ss", str(start_sec), "-i", str(SRC),
"-t", str(duration),
"-c", "copy", str(out_dir / f"{name}.mp3")]
print(f"{name}.mp3 [{dest}] start={start} cut={cut} end={end} sta_cut={sta_cut_sec}")
subprocess.run(cmd, check=True)
Print the sta_cut value during the run so it's easy to copy into route.json.
Step 4 — Run + verify file count
Run the splitter. Verify file count matches expected segment count. List the operational + archive output folders.
Step 5 — Update route.json
Replace placeholder sta_cut and sta fields with the splitter's output values. Replace placeholder sta refs with the descriptive basenames.
- Termini: omit
sta and sta_cut fields (no departure melody at end of route). Keep time.
- Stations IRL with no departure melody (e.g. 千葉 on the Sobu line): same treatment — omit
sta and sta_cut.
- Passing stations (
pa: []): omit sta, sta_cut, AND time — train doesn't stop, countdown comes from the next PA station's time.
Archive routing is handled by the splitter's per-segment dest tag in Step 3 — operational files land in audio/<line>/<diagram>/sta/, "archive"-tagged files land in audio/_archive/<line>/<diagram>/sta/ (mirror layout under _archive/, no route.json there). The _ prefix marks "preserved but not shipped" — _archive/ and _mock/ both follow this convention.
Step 6 — Sanity check refs vs disk
PYTHONUTF8=1 python -c "
import json
from pathlib import Path
ROOT = Path('D:/pids_jre_simulator') # absolute path — cwd persists across Bash calls
route = json.load(open(ROOT / 'audio/<line>/<diagram>/route.json', encoding='utf-8'))
sta_dir = ROOT / 'audio/<line>/<diagram>/sta'
on_disk = {p.stem for p in sta_dir.glob('*.mp3')} if sta_dir.exists() else set()
refs = {x for stop in route['stops'] for x in stop.get('sta', [])}
print(f'sta refs={len(refs)} on_disk={len(on_disk)}')
print(f'missing: {sorted(refs - on_disk)}')
print(f'unused on disk: {sorted(on_disk - refs)}')
"
Expected unused on disk: none — unused recordings (passing-station mp3s, other-platform takes) should already be in audio/_archive/. If a file appears in "unused" here, it's a leftover that needs to be relocated:
mkdir -p audio/_archive/<line>/<diagram>/sta
mv audio/<line>/<diagram>/sta/{file1,file2}.mp3 audio/_archive/<line>/<diagram>/sta/
Continue to Phase B.
Phase B — Validation + refinement
This phase is always run — for new splits and for revisits to existing routes. The validator is a gate: no STA folder ships with out-of-gap sta_cut values, and no route ships without a by-ear pass.
Step 7 — Backup before destructive ops
trim_sta_silence.py (next step) modifies mp3s in place (lossless lead/trail copy + lossy mid-gap re-encode) and patches route.json. Snapshot first, into audio_src/ so the backups stay gitignored:
mkdir -p audio_src/<line>/<diagram>
cp -r audio/<line>/<diagram>/sta audio_src/<line>/<diagram>/sta.bak
cp audio/<line>/<diagram>/route.json audio_src/<line>/<diagram>/route.json.bak
Mention this safety net in your pre-flight summary so the user knows you have a rollback path. Delete audio_src/<line>/<diagram>/sta.bak/ and route.json.bak only after the by-ear gate (Step 11) passes.
Step 7.5 — Splice source-recording artifacts (optional, only if pattern is present)
Some source recordings include capture artifacts that the standard trim/validate pipeline can't clean up. Two patterns surfaced so far (across multiple lines — patterns are recording-source-driven, not line-specific):
Pattern A — KAK transient (physical staff-machine cut captured in audio):
Loud transient peaking near digital ceiling (-4 to -7 dB), brief (0.2–0.5 s), sits between music end and voice start.
Only run this step if the user mentions it OR you spot the pattern: a single very loud short event near sta_cut on most files. Skip otherwise.
Detection. For each file, find the loudest 25 ms peak in [sta_cut − 1.5 s, sta_cut + 1.5 s]. If peak amplitude ≥ -15 dB (vs. typical music -20 to -25 dB), it's a KAK. Walk outward from the peak using a 25 ms peak-amplitude envelope until amplitude drops below -25 dB; that defines the splice window. Reject results with width > 1.5 s — the walk escaped into music content.
Why raw peak amplitude, not RMS: RMS averaging over 5–10 ms windows dilutes transient peaks down to -18 to -22 dB, indistinguishable from music. Use raw max(|y|) over a short window.
Detection script template:
import librosa, numpy as np, json
from pathlib import Path
route = json.loads(Path("audio/<line>/<diagram>/route.json").read_text(encoding="utf-8"))
WIN_MS = 25; THRESH_PK_DB = -25; MIN_PEAK_DB = -15; MAX_WIDTH = 1.5; PAD = 0.03
def peak_env(y, sr, win_ms):
win = int(sr * win_ms / 1000)
n = len(y) // win
return np.array([np.abs(y[i*win:(i+1)*win]).max() for i in range(n)]), win/sr
splices = []
for stop in route["stops"]:
for name in stop.get("sta", []):
cut = stop.get("sta_cut")
p = Path(f"audio/<line>/<diagram>/sta/{name}.mp3")
if cut is None or not p.exists(): continue
y, sr = librosa.load(p, sr=22050, mono=True)
env, dt = peak_env(y, sr, WIN_MS)
env_db = 20 * np.log10(env + 1e-10)
times = np.arange(len(env_db)) * dt
mask = (times >= max(0, cut - 1.5)) & (times <= min(len(y)/sr, cut + 1.5))
if not mask.any(): continue
idx = np.where(mask)[0]
peak_idx = idx[env_db[idx].argmax()]
if env_db[peak_idx] < MIN_PEAK_DB: continue
i_l, i_r = peak_idx, peak_idx
while i_l > 0 and env_db[i_l] > THRESH_PK_DB: i_l -= 1
while i_r < len(env_db)-1 and env_db[i_r] > THRESH_PK_DB: i_r += 1
kak_start = max(0, times[i_l] - PAD)
kak_end = min(len(y)/sr, times[i_r] + PAD)
if kak_end - kak_start > MAX_WIDTH: continue
splices.append((name, round(kak_start, 3), round(kak_end, 3)))
print(f"{name} peak={env_db[peak_idx]:.1f}dB splice [{kak_start:.3f}, {kak_end:.3f}] width={kak_end-kak_start:.2f}s")
Surface to user before splicing. Show each file's proposed splice range + peak dB. Files that don't trigger the threshold get reported as "no KAK detected" — they may still be valid (just no transient), or anomalous (wrong recording entirely). Wait for OK.
Splice + sta_cut adjustment. Apply ffmpeg -filter_complex splice (same recipe as Step 12 — atrim + concat). For sta_cut adjustment per file:
kak_end ≤ sta_cut → shift sta_cut down by full splice width
kak_start ≥ sta_cut → no change (KAK was after the cut point)
kak_start < sta_cut < kak_end → snap sta_cut to kak_start
Then proceed to Step 8 (trim) normally — the spliced files now have a clean music→silence→voice structure that trim_sta_silence + detect_sta_cut handle correctly.
Pattern B — "2nd-loop snippet" (recording captured the start of a 2nd melody loop before the staff cut):
Pattern: music (1st loop) → tiny silence (~0.2 s, between-loops gap) → brief music pulse (0.1–0.7 s = start of 2nd loop) → silence (1–3 s) → voice. The 2nd-loop pulse is real music, same melody, just truncated. If left in place, pressing PageUp during the 1st loop can land in the inter-loop silence and the simulator plays a confusing "music–silence–music(<0.5s)–silence–voice" sequence.
Detection — don't try to do this algorithmically across a whole route; mid-loop pulses look just like voice fragments to amplitude detectors. Surface the pattern when the user reports it on a specific station, then probe that file's [music_end, voice_start + 1 s] window manually with finer-grained RMS (Step 12 recipe) to locate the snippet.
Splice rule (handles both the snippet AND the long post-snippet silence in one pass):
keep_until = music_1_end + 0.1 s (just past full melody, before inter-loop silence/snippet)
skip_until = voice_start − 1.0 s (leaves ~1 s of silence before voice in the new file)
- After splice, set
sta_cut = round(max(music_1_end, new_voice_start − 0.5), 1)
where new_voice_start = voice_start − (skip_until − keep_until)
Always preview after splicing — when the long post-snippet silence is genuinely long (>2 s like keiyo's tokyo-end stations), the resulting 1 s gap may still feel abrupt by-ear and the user may want to extend it. Use the verifier's interactive trim (Step 11) for fine adjustment.
Pattern C — duplicate intro / stutter / mid-music repeat
Source recordings occasionally have a tiny stutter at the start (e.g., the first 0.1–0.3 s of music plays twice) or a fully-repeated melody (the entire melody plays twice back-to-back). These are per-file issues — don't try to detect them. Hand off to the verifier's interactive trim (Step 11) and let the user nudge start/end markers by-ear.
Step 8 — Trim silences (in-place, modifies files)
Trims leading + trailing silence to ~0.2 s pads (lossless stream-copy) and the mid-file silence between music end and voice start to ~1 s (re-encodes, only when detection confidence is high and gap is in a sane range). With --route, patches route.json sta_cut values down by lead_trim + mid_trim.
PYTHONUTF8=1 uv run python _dev_scripts/trim_sta_silence.py audio/<line>/<diagram>/sta \
--route audio/<line>/<diagram>/route.json
Idempotent — re-running on already-trimmed files is a no-op.
Step 9 — Validate sta_cut placement
Detector compares each sta_cut against the [music_end, voice_start] window it derives from the file. Flags EARLY (sta_cut in music) or LATE (sta_cut in voice) — both are illegal UX:
- EARLY (
sta_cut < music_end): simulator briefly replays a music tail before voice — music returns when the user expected to skip past it. Jarring.
- LATE (
sta_cut > voice_start): simulator clips the first syllable of the announcement. Always wrong.
PYTHONUTF8=1 uv run python _dev_scripts/detect_sta_cut.py audio/<line>/<diagram>/sta \
--truth audio/<line>/<diagram>/route.json
Expected pattern post-trim: many stations flag EARLY by 0.6–2 s. This is not a bug — trim_sta_silence.py's flat total_shift = lead_trim + mid_trim over-corrects when the original sta_cut sat near the original music_end (vs. after voice_start). The propose-and-apply step below fixes them.
Step 10 — Propose corrections + apply
For each EARLY/LATE flag, propose the correction using the auto-set rule:
sta_cut = round(max(music_end, voice_start - 0.5), 1)
(Sits 0.5 s pre-voice when the gap allows; falls back to voice_start when the gap is too narrow.)
Surface the diff (current → proposed, with the [music_end, voice_start] window) as a table. Do not auto-apply — wait for user confirmation. If detector confidence < 0.7 OR the proposal looks wrong relative to the user's hand-set value → flag for re-listen instead of proposing.
After user accepts, patch route.json (1-decimal precision is fine; the runtime is float-typed):
import json
from pathlib import Path
p = Path("audio/<line>/<diagram>/route.json")
route = json.loads(p.read_text(encoding="utf-8"))
updates = {"ueno": 11.2, "akabane": 9.9, ...}
for stop in route["stops"]:
for sta in stop.get("sta", []):
if sta in updates:
stop["sta_cut"] = updates[sta]
p.write_text(json.dumps(route, ensure_ascii=False, indent=4) + "\n", encoding="utf-8")
Re-run Step 9 to confirm in gap: N/N.
Step 11 — By-ear verification gate
The detector is a feature-based heuristic; the by-ear gate is the ground truth. Run the GUI verifier:
PYTHONUTF8=1 uv run python _dev_scripts/verify_sta_listen.py audio/<line>/<diagram>
Per station, the script:
- Plays
[0, 3 s] — confirms the music head is intact (no clipped attack).
- Brief silence (~0.5 s).
- Plays
[sta_cut − 3 s, EOF] — gives 3 s of music tail, then the cut, then the voice. The cut transition is what you're listening for.
The window has a clickable sidebar listing all STAs with their current verdict (✓ ✗ ·). Click any row to jump. Pass/Fail/Replay buttons + P/F/R keys. ↑/↓ navigates linearly. Q/Esc to quit.
Per-station notes (✎ row above the seek bar): click or press E to add/edit a note (e.g. "look for double-loop at start", or a FAIL reason). Enter saves, Esc cancels. PASS auto-resolves the note (renders dim with strike-through). Notes persist in the results JSON across runs.
Cut-marker beep: a short 880 Hz beep fires once per playback when the tail crosses sta_cut, marking the exact transition point so it's easy to hear whether the cut lands cleanly.
Interactive trim (for files where the propose+splice pipeline can't cleanly fix the issue — e.g., per-file stutters, duplicate intros, idiosyncratic snippets):
| Key | Action |
|---|
[ / ] | start-trim ±0.1 s (Shift = ±0.01 s for fine) |
, / . | end-trim ±0.1 s (Shift = ±0.01 s) |
R | replay (preview the pending trim — head plays from trim_start, tail stops at duration − end_trim) |
T | apply trim — splices the file lossless via ffmpeg, shifts sta_cut by −start_trim, persists to route.json |
Z | reset pending trim |
The trim regions show as red overlays on the seek bar. Status line below the bar previews the new duration and new sta_cut. Switching stations discards pending trim. Use this for any per-file issue that doesn't fit a generic detector — keiyo's shin-kiba "0.2 s stutter at start" was an example.
Single-station retest when iterating on a fix:
PYTHONUTF8=1 uv run python _dev_scripts/verify_sta_listen.py audio/<line>/<diagram> --only kumagaya
Results merge into audio_src/<line>/<diagram>/sta_verify_results.json (auto-creating the dir under the gitignored audio_src/ tree) — verdicts for stations not tested this run are preserved from the prior JSON. Read this file to pick up FAILs:
import json
from pathlib import Path
results = json.loads(Path("audio_src/<line>/<diagram>/sta_verify_results.json").read_text())
fails = [it for it in results["items"] if it["verdict"] == "FAIL"]
Step 12 — Investigate FAILs
For each FAIL, the detector's reported music_end / voice_start may not match reality. Probe the waveform with finer-grained RMS to see what's actually there:
import librosa, numpy as np
y, sr = librosa.load("audio/<line>/<diagram>/sta/<sta>.mp3", sr=22050, mono=True)
hop = int(sr * 0.05)
rms_db = 20 * np.log10(librosa.feature.rms(y=y, hop_length=hop)[0] + 1e-10)
for i, t in enumerate(np.arange(len(rms_db)) * 0.05):
if <window_start> <= t <= <window_end>:
print(f"{t:>6.2f} {rms_db[i]:>7.1f} dB")
Look for the silence floor (-60 dB or below) marking the real music→voice gap.
Edge case worth knowing — "zero-gap" detector false positive (kumagaya pattern):
- Symptom: detector reports
music_end == voice_start with high confidence, mid-trim didn't fire, by-ear gate fails.
- Root cause: detector's
SEARCH_WINDOW_FRAMES = 12 (= 1.2 s after change-point) was too tight to reach the real silence gap. Cut-point landed mid-decay; real silence started further out.
- Fix: probe the waveform manually (above), identify the real silence boundaries, then call
trim_middle_gap directly with explicit values:
ffmpeg -y -loglevel error -i audio/<line>/<diagram>/sta/<sta>.mp3 \
-filter_complex "[0:a]atrim=0:<keep_until>,asetpts=PTS-STARTPTS[a1];[0:a]atrim=<skip_until>,asetpts=PTS-STARTPTS[a2];[a1][a2]concat=n=2:v=0:a=1[out]" \
-map "[out]" -q:a 2 <sta>.tmp.mp3
mv <sta>.tmp.mp3 audio/<line>/<diagram>/sta/<sta>.mp3
Where keep_until = music_end + 0.5 and skip_until = voice_start - 0.5 (uses the same arithmetic as trim_middle_gap). Then update sta_cut to round(max(music_end, voice_start - 0.5 - 0.5), 1) accounting for the splice. Re-run Steps 9 + 11 to confirm.
If the file is locked when mv runs ("Device or resource busy"), the verifier or another player is holding it — close it and retry.
Hostile-recording pattern — combine fixes in ONE splice
When the source recording has multiple problems in the cut zone — sta_cut placed mid-music, residual KAK transient post-cut, no clean silence gap, voice attacks at digital ceiling — fix them all in a single ffmpeg operation. Don't iterate "fix KAK → user verifies → adjust sta_cut → user verifies → add silence pad → user verifies." That triples user verification time.
(First surfaced on Yamanote recordings — pattern is recording-source-driven, not line-specific.)
The combined operation:
- Cut music body before its natural sharp end — addresses pre-cut KAK perception (the music's hard staff-cut moment can sound clicky in the verifier preview). Use
atrim=0:<music_cut> ending slightly before the music's natural end (e.g. 100–200 ms inside the music body).
- Insert artificial silence to meet the
~0.3 s pre-voice convention. Hostile-pattern files don't have a natural silence gap wide enough; generate one with anullsrc=channel_layout=stereo:sample_rate=22050,atrim=duration=0.30.
- Skip KAK + first voice burst — splice through to the inter-syllable quiet zone (or directly to voice content if no quiet zone exists). Use
atrim=<voice_resume> for the second segment.
- Crossfade both junctions to avoid audible click at sample-level discontinuities. Use
acrossfade=d=0.03:c1=tri:c2=tri between music and silence (30 ms is good for music-side fade-out), and acrossfade=d=0.02 between silence and voice (20 ms is enough since silence side is already at zero).
- Set sta_cut at the start of artificial silence — gives the convention's 0.3 s pad before voice attack arrives.
Template:
ffmpeg -y -loglevel error -i audio/<line>/<diagram>/sta/<sta>.mp3 \
-filter_complex "
[0:a]atrim=0:<music_cut>,asetpts=PTS-STARTPTS[music];
anullsrc=channel_layout=stereo:sample_rate=22050,atrim=duration=<silence_dur>[silence];
[0:a]atrim=<voice_resume>,asetpts=PTS-STARTPTS[voice];
[music][silence]acrossfade=d=0.03:c1=tri:c2=tri[ms];
[ms][voice]acrossfade=d=0.02:c1=tri:c2=tri[out]
" \
-map "[out]" -q:a 2 <sta>.tmp.mp3
mv <sta>.tmp.mp3 audio/<line>/<diagram>/sta/<sta>.mp3
Then update sta_cut to <music_cut> (= start of inserted silence).
Verify with the −8 dB voice-attack threshold (not the convention's voice_start which assumes a sharp onset — hostile-pattern voices ramp in and cross −8 dB earlier than the peak). Aim for the gap from sta_cut to first window > −8 dB to be 280–340 ms. If short, increase silence_dur by ~100 ms and redo.
Anti-pattern: doing this in 2-3 ffmpeg passes (first KAK splice, then sta_cut adjustment, then silence pad). Each pass requires user verification. Combine all three into the single template above.
Route-level gap alignment — audit ALL stops, not just FAILs
Before declaring a route done, audit the pre-voice gap (sta_cut → first sample > −8 dB) across every stop, including the originally-PASSed ones. The FAIL-driven workflow only fixes the obvious ones; the PASSed files often have widely varying gaps (anything from 50 ms to 900 ms) just because their natural silence floors happen to be different lengths. That variance is audible — a PageUp on station X feels snappier than station Y on the same line.
Target: all stops within ~250–400 ms of sta_cut → voice attack. Two cheap fixes converge them:
- LONG (gap > 400 ms): route.json edit only. Set
new_sta_cut = round(voice_attack_time − 0.30, 1). No audio change. Files with deep silence floors before voice (sta_cut placed where music decay just ended) tend to land here — moving sta_cut later gets the simulator to start playback closer to voice.
- SHORT (gap < 250 ms): insert artificial silence AT
sta_cut position (no splice junction needed if the file is otherwise clean — typical for originally-PASSed files). Use:
[0:a]atrim=0:<sta_cut>,asetpts=PTS-STARTPTS[pre];
anullsrc=channel_layout=stereo:sample_rate=22050,atrim=duration=<silence_dur>[silence];
[0:a]atrim=<sta_cut>,asetpts=PTS-STARTPTS[post];
[pre][silence]concat=n=2:v=0:a=1[ps];
[ps][post]concat=n=2:v=0:a=1[out]
silence_dur ≈ 0.30 − current_gap. No sta_cut change needed (the inserted silence pushes voice content later within the file).
Run the audit script after each fix batch. The skill is "done" when 0 stops fall outside 250–400 ms.
Step 13 — Cleanup
After by-ear gate passes (PASS for all stations or user explicitly accepts FAILs):
rm -rf audio/<line>/<diagram>/sta.bak audio/<line>/<diagram>/route.json.bak
The audio_src/<line>/<diagram>/sta_verify_results.json artifact can be kept (audit trail) or removed — it gets overwritten on next run anyway, and audio_src/ is gitignored either way.
Conventions
STA timestamps file format (sta_timestamps.txt)
Each line is one segment with explicit boundaries:
0:12 1:08(cut) to 1:18 都賀(2) gota del viento
2:41 3:01 3:06 物井(1) gota del viento
4:28 4:54 4:57 酒々井
6:19 6:47 till the end 空港第2ビル チャイム3B4
- Three timestamps positionally:
start, cut, end. The (cut) and to annotations on some lines are documentation; the compact form (just three numbers) means the same thing.
(N) after the station name = JR platform number where this melody plays IRL. Optional.
- Trailing string = official melody name. Optional. Can be Japanese (katakana / kanji) or English.
- "till the end" in the end slot = segment runs to EOF.
STA filename convention
{station}_{platform}_{song-id}.mp3
- Station first (easier to scan when listing files), then platform, then song.
- Underscore between fields, hyphens within a field.
- Only
station is required. platform and song-id are optional — drop along with the separator:
narita_3_soyokaze.mp3 — all three known
narita_3.mp3 — song unknown
shisui_gota-del-vient.mp3 — no platform recorded (rare)
shisui.mp3 — only station known
- Re-use surfaces as a glob on the trailing field:
ls *_gota-del-vient.mp3 shows every station that plays that song.
- No metadata sidecar. The filename IS the store. When a song is identified later, rename the file + update its
sta ref in route.json in the same pass.
- Variants are out of scope. Two recordings of "the same song with slight differences" each get their own song-id slug. Differences in the trailing closing-door announcement (different staff voices, phrasing) across platforms/routes are not captured —
tsuga_2_gota-del-vient and monoi_1_gota-del-vient share song identity, NOT recording identity.
Japanese → ASCII slug rules
Hepburn romanization, macrons stripped, lowercase, hyphens for word boundaries — same rule used for station name slugs throughout the repo.
| Source | Slug |
|---|
| 東京 | tokyo |
| 越中島 | etchujima |
| 高輪ゲートウェイ | takanawa-gateway |
| スイートコール | suito-koru |
| フラワーショップ | furawa-shoppu |
| チャイム3B4 | chaimu-3b4 |
For non-Japanese names: lowercase, spaces → hyphens, strip apostrophes/most punctuation, spell out & as and. Don't Stop → dont-stop, Rock & Roll → rock-and-roll.
sta_cut field
Seconds from the START of the file where the melody is cut and the closing-door announcement (station-attendant voice, "ドアが閉まります" / "the doors will be closing") begins. IRL the music is cut — not faded — because the schedule rarely allows the full melody to play; the staff hard-cuts directly to the doors-closing voice. Computed at split time as cut_timestamp - start_timestamp.
Hard placement rule (both sides strict)
sta_cut MUST satisfy music_end <= sta_cut <= voice_start — i.e. land inside the silence gap between the music's hard cut and the voice's first frame. Both error modes are unacceptable UX (see Step 9 above).
sta_cut accepts integer or one-decimal float. Use the float precision when integer rounding would push outside the gap (common when the gap is < 1 s wide). The default auto-set strategy is round(max(music_end, voice_start - 0.5), 1).
Leading + trailing silence targets
Every STA file ships with ~0.2 s of silence at each end:
- Leading silence ~0.2 s (true silence < −40 dB). Gives the audio attack a tiny safety pad while feeling snappy when the simulator triggers it. Anything beyond ~0.5 s feels broken.
- Trailing silence ~0.2 s. Mostly invisible to the user (file just stops), but critical for
detect_sta_cut.py accuracy — the detector uses the last 3 s of the file as a "voice exemplar" for music→voice classification. Trailing silence pollutes that exemplar and degrades detection (one Sobu file's voice exemplar was 100% silence, blowing the detector by +5.8 s).
route.json field rules at split output
sta: list of basenames. Often one entry; can have multiple variants.
sta_cut: number (integer or one-decimal float), seconds from start. Use float when the music→voice silence gap is narrow and integer rounding would land in music or voice instead of the silence.
- Terminus: omit
sta and sta_cut (no departure from end-of-line). Keep time.
- Stations IRL with no melody (e.g. 千葉 on Sobu): omit
sta and sta_cut. Keep time.
- Passing stations (
pa: []): omit sta, sta_cut, AND time.
Gotchas
- Discussion-first. Don't generate the splitter or update route.json until the user confirms the parse. Format variance between sources is normal — surprises happen. Same applies before running destructive trim/splice ops.
- Backup before destructive ops.
trim_sta_silence.py and any ffmpeg -filter_complex splice modifies files in place. Snapshot the dir + route.json first; only delete backups after the by-ear gate passes.
- CWD persists across Bash calls in this harness. Use absolute paths in verification scripts, not relative —
Path('audio/...') will resolve from wherever the last cd left you.
- Unused-platform STA recordings (other-platform takes for the train you're routing) belong in
audio/_archive/<line>/<diagram>/sta/, NOT in operational sta/. Tag them "archive" in the splitter's SEGMENTS so the script routes them automatically — don't mv them after the fact.
- Passing-station mp3s on disk that aren't in the route (the train doesn't stop there) → also belong in
_archive. Surfaces as "unused on disk" in the sanity check.
- Trailing-announcement gap < ~5 s is suspicious — the closing-door section between
cut and end usually runs 5–20 s. If you see only 2–3 s, double-check end against the source.
- Splitter scripts stay with their source folder (
audio_src/<line>/<diagram>/), not in a shared workflow folder. The audit trail of "how this batch was split" stays with the data. Note: the entire audio_src/ tree is gitignored — only the cut output under audio/<line>/<diagram>/ ships.
- Formats vary between sources — even within the same line/diagram, two STA recordings may use different timestamp conventions (3 timestamps vs 2). Don't try to unify; each source gets its own
split_sta_<describer>.py.
- Trailing digits in station romanization (e.g.,
airport-terminal-2) are part of the station name, not platform. Filename position-parsing has no parser, so this is human-readable ambiguity only — not a bug to fix.
- Don't create a metadata JSON sidecar. The filename IS the metadata store. If
sta_meta.json shows up, that's a previous experiment that should be removed.
- Front-half placeholders are fine. When STA source covers only part of a route (e.g., from-某-station-onward), the unsplit stops keep their placeholder
sta refs until the rest arrives. They'll fail validate_data.py's file-existence check until then — that's expected.
- Detector zero-gap pattern → real gap is outside the search window. When the detector returns
music_end == voice_start with high confidence, that's NOT a genuinely tight transition — it's a false positive. Probe the waveform manually (Step 12) and run the manual mid-trim recipe.
- Source-recording transients (KAK). Some source recordings have a loud physical-cut transient captured at the staff-machine cut moment (seen on Keiyo + Yamanote so far; recording-source-driven, not line-specific). If you spot a -4 to -7 dB peak near
sta_cut, run Step 7.5 to splice it out before normal trim. Without splicing, the transient gets played at full volume during cut transitions — jarring UX. The detector also misclassifies the KAK's silence boundary as music_end, producing zero-gap false positives.
- Most stations flag EARLY immediately after
trim_sta_silence.py — expected, propose-then-apply fixes them. Don't try to "fix" the trim script's total_shift math; the propose-then-apply round trip is the design.
Documentation hook
After Phase A split / Phase B verification lands: if this work surfaced anything line-specific not already in audio/README.md — IRL melody quirk (no-melody station, elaborate-melody region), per-line filename-convention deviation, schema-corner-case usage — propose an entry. Decline if work was routine. (Recording-source patterns like KAK transient / hostile-recording are NOT line-specific; they stay in the skill's Step 7.5 + Gotchas.)
Out of scope
- Variant / arrangement modeling — current scheme captures song identity, not recording identity. If/when fidelity matters, extend the convention or add a sidecar then. Don't pre-build for it.
- Cross-route audio sharing (e.g., 東京 STA used on Sobu AND Tokaido) — for now, duplicate the file. If it becomes painful, factor out an
audio/_shared/ later.
- Audio normalization / loudness leveling — out of this skill. The simulator does -15 LUFS at runtime.
- PA processing — see the pa-make skill.
Related
- pa-make skill — PA workflow (separate; PA has different conventions, no
sta_cut)
audio/README.md — per-line IRL + sim quirks catalog (write-gate target above)
DATA_FORMAT.md — route.json schema reference (field meanings, validation rules)
_dev_scripts/trim_sta_silence.py — trim leading/trailing/mid-gap silence
_dev_scripts/detect_sta_cut.py — validate sta_cut placement
_dev_scripts/verify_sta_listen.py — by-ear verification GUI
_dev_scripts/validate_pa.py — PA silence-bracket validator (PA-only; lives here for proximity to other audio tools)
validate_data.py — checks audio files referenced by routes exist on disk