| name | pa-make |
| description | End-to-end PA (public-address announcement) processing for a route โ optional Whisper-based timestamp generation, splitting the source mp3 into per-segment files, route.json updates, and silence-bracket validation. PA-only; for STA (departure melody) processing see sta-make. |
| triggers | ["/pa-make","process pa","split pa","generate pa timestamps","transcribe pa","whisper pa"] |
Purpose
Take a continuous PA source mp3 + (optional Whisper-derived) timestamps and produce simulator-ready per-segment PA mp3s plus the matching route.json pa arrays. Each segment is one announcement (either {prev}-dep after a station or {this}-arr approaching one).
The pipeline has three logical phases:
- Generate timestamps (optional): use Whisper to transcribe the source and identify per-station PA cluster start times. LLM-mediated โ see Step 0. Skip if you already have a hand-written
timestamps.txt.
- Split: a per-source ad-hoc splitter cuts the source mp3 into the per-segment files using the timestamps.
- Validate: silence-bracket check on the cut output.
For STA (departure melody + closing-door announcement) work, see the sta-make skill (separate; STA has its own conventions and tools โ sta_cut placement, by-ear verification, etc.).
When to run
The user points at a working folder like audio_src/<line>/<diagram>/ containing the source PA mp3 and either a timestamps.txt (skip Step 0) or a request to derive one (run Step 0).
Required input
- Path to the source mp3 (typically
audio_src/<line>/<diagram>/src.mp3).
- Which line + diagram the splits belong to (target:
audio/<line>/<diagram>/pa/) โ usually mirrors the source path.
- For Step 0 only: start offset (where the route's relevant content begins) and route context (line, train type, destination, stop list).
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>/pa/*.mp3 and route.json). Anything else โ source mp3s, trimmed sources, Whisper transcripts, timestamps, splitter scripts, intermediate proposals โ is local-only.
audio_src/
โโโ sobu/1217F/ # mirrors audio/sobu/1217F/
โโโ src.mp3 # continuous PA source
โโโ src_from<MIN>.mp3 # (Step 0 trim, if used)
โโโ src_from<MIN>_transcript.json # (Step 0 Whisper output)
โโโ proposal.json # (Step 0 hand-built mapping, before formatter)
โโโ proposed_timestamps.txt # (Step 0 formatter output)
โโโ timestamps.txt # final PA timestamps
โโโ split_pa.py # generated by this skill
Cross-PC: gitignored; manual cloud sync if you switch machines mid-route.
Process
Step 0 โ Generate PA timestamps with Whisper (optional)
Skip if the user already has a hand-written timestamps.txt. Run when starting from scratch on a new PA source or regenerating.
This step is LLM-mediated for the matching stage on purpose โ there's no deterministic clusterer. Each route has quirks (homophone mistranscriptions, special pre-mamonaku passes, English/Japanese code-switching) that benefit from text understanding rather than rigid rules.
0.1 Trim the source. Stream-copy is lossless. Output goes next to the original src.mp3.
ffmpeg -y -loglevel error -ss <START_SECONDS> -i audio_src/<line>/<diagram>/src.mp3 \
-c copy audio_src/<line>/<diagram>/src_from<MIN>.mp3
Naming: src_from<MIN>.mp3 (e.g., src_from12.mp3 for "trimmed at 12-minute mark"). Self-documenting.
0.2 Transcribe with Whisper.
uv run _dev_scripts/transcribe_pa.py audio_src/<line>/<diagram>/src_from<MIN>.mp3 \
--model large-v3 --device cuda
- Model: always
large-v3 for production. Medium misses bilingual content (forces auto-detected language for the whole pass) and produces more homophone errors. Speed isn't the bottleneck on GPU.
- Device:
cuda if GPU available, cpu otherwise (5โ10ร slower).
- Output: JSON next to the input mp3 by default.
0.3 Read the transcript and map clusters to stations. Open the JSON. Read it from start to end and identify each PA cluster's start time.
- A "PA cluster" = contiguous group of announcements about one station event (approach, route info, interchange, English version). Typical span: 20โ40 s.
- Cluster start markers (in order of usefulness):
- Japanese
ๆฌกใฏใX or ๆฌกใฏ XใX (X repeated) โ kicks off the {prev}-dep cluster.
- English
The next station is X[, JOXX] โ equally clean, useful when Japanese has a homophone error.
- JO codes
JO19, JO20, ... โ map 1:1 to sta_code in route.json. Highest-confidence anchor.
- Japanese
้ใใชใใXใX (X repeated) โ kicks off the separate {this}-arr cluster.
- Two-PA stations (most): {prev}-dep cluster + {this}-arr cluster.
- One-PA stations: termini, IRL no-arrival-PA stations, or tight {prev}-dep that runs straight into the stop. Use 1 timestamp.
0.3.a โ Homophone watch-list (build per-route as new ones surface)
Whisper's large-v3 still mistranscribes some rare station names. NOT bugs to fix โ model's best phonetic guess. Recognise them and cross-reference with the English transcript or JO code:
| Whisper output | Actual station | Encountered on |
|---|
| ๆดฅ่ณ | ้ฝ่ณ | Sobu |
| ๅๆฐด | ้
ใ
ไบ | Sobu |
| ๆด้ฒ็บ / ใใฏใญ็บ | ้ฆฌๅฐ็บ | Sobu |
| ็ฆๆญขๅธณ | ้ฆ็ณธ็บ | Sobu (intermittent โ mostly correct, sometimes wrong) |
Add new entries when other routes surface their own quirks.
0.3.b โ Special case: pre-mamonaku approach passes
Some stations have additional approach warnings broadcast before the standard ้ใใชใ X โ typically when the train has to pass distinctive points (level crossings, curves, restricted-speed zones) on its way in. These are part of the {this}-arr cluster IRL, NOT the {prev}-dep cluster.
Symptom in the transcript: a noticeable gap (10โ20 s) between the previous cluster's safety/info messages and the actual ้ใใชใ, with extra ใใฎๅ
้ป่ปใๆบใใพใใฎใงโฆ or English We will be changing to another track in between.
When you see this pattern, set the {this}-arr timestamp at the start of the pre-mamonaku cluster, not at the ้ใใชใ line. Sobu's ๅธๅท is the canonical example (16:06 vs the cleaner 16:21). The user will recognise these per route โ flag when you spot one.
0.4 Output proposal + apply safety margin via the formatter. Build the mapping JSON:
{
"stations": [
{"name": "ๆฐๆฅๆฌๆฉ", "voice_onsets": [742.5]},
{"name": "้ฆฌๅฐ็บ", "voice_onsets": [784.2]},
{"name": "ๆฐๅฐๅฒฉ", "voice_onsets": [863.8, 908.7]},
{"name": "ๅธๅท", "voice_onsets": [940.5, 966.0]}
]
}
voice_onsets is in original-source seconds โ add the trim offset back since Whisper's output is relative to its input (the trimmed file).
cat proposal.json | uv run _dev_scripts/format_pa_timestamps.py > proposed_timestamps.txt
Safety margin via floor(): the cut sits at or before the actual voice (โค 1 s of leading silence retained), never inside the voice.
0.5 Diff vs existing + discuss before writing. If the route already has a timestamps.txt, diff first. Most diffs should be โค 1 s. Larger diffs warrant flag + discussion โ they're either real corrections or pre-mamonaku special cases.
0.6 Once user approves, write into audio_src/<line>/<diagram>/timestamps.txt. Continue to Step 1.
Step 1 โ Inspect
ls the source folder. Read timestamps.txt. If split_pa.py already exists, read it โ the user may have customised.
Step 2 โ Parse + discuss BEFORE acting
Before generating any script or touching route.json, surface to the user:
- Total segment count.
- Per-station segment count โ don't assume "everyone has 2 timestamps".
- Suspicious gaps โ very short (<10 s) or very long (>30 s within an active stretch) deserve a flag (back-to-back PAs, jingle/voice splits, non-PA filler).
- Any 1-timestamp stations โ typically use
{prev}-dep. Confirm.
Wait for user confirmation before generating.
Step 3 โ Generate splitter script (per-source, ad-hoc)
Splitters are per-source artifacts, not a maintained library. The format of the timestamps file varies between sources. Each source gets its own script reflecting its specific format.
Naming: split_pa.py if it's the only PA source for this diagram, otherwise split_pa_<describer>.py. The script lives in the source folder so the audit trail of "how this batch was split" stays with the data.
PA splitter pattern โ each timestamp = start of one segment, ending at next chronological timestamp; last runs to EOF:
"""Split src.mp3 into N PA segments for audio/<line>/<diagram>/pa/."""
import subprocess, sys
from pathlib import Path
SRC = Path(__file__).parent / "src.mp3"
OUT = Path(__file__).resolve().parents[3] / "audio" / "<line>" / "<diagram>" / "pa"
SEGMENTS = [
("12:22", "tokyo-dep"),
]
def to_sec(ts):
m, s = map(int, ts.split(":"))
return m * 60 + s
def main():
if not SRC.exists():
print(f"ERROR: source not found: {SRC}", file=sys.stderr); return 1
OUT.mkdir(parents=True, exist_ok=True)
for i, (start, name) in enumerate(SEGMENTS):
start_sec = to_sec(start)
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", str(start_sec), "-i", str(SRC)]
if i + 1 < len(SEGMENTS):
cmd += ["-t", str(to_sec(SEGMENTS[i + 1][0]) - start_sec)]
cmd += ["-c", "copy", str(OUT / f"{name}.mp3")]
print(f"[{i+1:2d}/{len(SEGMENTS)}] {name}.mp3 (start={start})")
subprocess.run(cmd, check=True)
print(f"\nDone. {len(SEGMENTS)} files written to {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
Step 4 โ Run + verify file count
Run the splitter. Verify file count matches expected segment count. List the output folder.
Step 5 โ Update route.json
Replace placeholder pa arrays (numbered ["1", "2", ...]) with descriptive basenames.
- Termini: omit
sta and sta_cut fields (no departure melody at end of route). Keep time. (sta/sta_cut are STA's job โ see split-audio skill.)
- Stations IRL with no departure melody: same treatment.
- Passing stations (
pa: []): keep them passing โ no sta, no sta_cut, no time.
Step 6 โ Sanity check
Cross-reference route.json pa refs against files on 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'))
pa_dir = ROOT / 'audio/<line>/<diagram>/pa'
on_disk = {p.stem for p in pa_dir.glob('*.mp3')} if pa_dir.exists() else set()
refs = {x for stop in route['stops'] for x in stop.get('pa', [])}
print(f'pa: refs={len(refs)} disk={len(on_disk)} missing={sorted(refs-on_disk)} unused={sorted(on_disk-refs)}')
"
Step 7 โ Trim silence to voice onset
uv run python _dev_scripts/trim_pa_silence.py audio/<line>/<diagram>/pa
Voice-onset detection replaces the simple -40 dB gate. The old validate_pa.py check was too crude โ it couldn't distinguish recording hiss (-45 to -40 dB) from actual voice, so files with noisy sources kept multi-second noise leads while the validator reported them as "clean."
trim_pa_silence.py uses the same onset principle as the STA pipeline: detect where voice actually starts (first frame above noise_floor + 12 dB with a +6 dB/30ms attack slope), then stream-copy trim to leave ~80 ms of silence before voice onset. Trail trimming is left to validate_pa.py's simple threshold (trail is less critical).
- Idempotent on already-trimmed files โ onset is detected, gap โค TARGET โ skip.
- Noisy-source recordings (flat hum at -50 to -45 dB) โ the onset detector sees the flat profile vs. the sharp voice attack and trims correctly. The old -40 dB gate saw the hum as "content" and skipped.
Step 7.3 โ Trim-amount summary
After trimming, parse the output and present files sorted by most-trimmed โ least. User needs to see which files had the most silence removed to prioritize by-ear verification. Format:
Top trimmed (lead removed):
18 2220ms
22 2170ms
36 1950ms
...
Construct by extracting trim_lead Xms from each line, sort descending, show all (or top ~12 if >30 files).
Step 7.5 โ Final validate
uv run _dev_scripts/validate_pa.py audio/<line>/<diagram>/pa
Fast -40 dB gate after trimming. Expect 0 flags on low-noise files; a few flags may remain on files where the noise floor sits just above -40 dB in the 80ms pad โ treat as false-positive suggestions, not failures. The onset detector is the ground truth.
Step 7.6 โ By-ear verification
uv run python _dev_scripts/verify_pa_listen.py audio/<line>/<diagram>
Plays first 3s of each PA segment (head), then last 3s before EOF (tail) โ sequential auto-playback so you hear both ends. Seeks via pygame.mixer.music.play(start=offset) (same mechanism as STA verifier). PASS/FAIL per file, notes editable. Verdicts persist to audio_src/<line>/<diagram>/pa_verify_results.json. Includes both pa and pa_at_station entries.
Keys: P pass F fail R replay E edit note โโ navigate Q/ESC quit
IMPORTANT: do not launch this from bash tool โ pygame audio mixer won't reach the user's speakers. Generate the --only filter from NOT_REVIEWED entries and present it; user runs it themselves.
Conventions
Timestamps file format
timestamps.txt โ each line is one station, with 1 or 2 timestamps:
ๆฐๆฅๆฌๆฉ 12:22
้ฆ็ณธ็บ 13:31
ๆฐๅฐๅฒฉ 14:24 15:08
็จฒๆฏ 19:17 19:24
ๆ็ฐ็ฉบๆธฏ 26:42
- 1 timestamp = single PA at that station (typically
{prev}-dep).
- 2 timestamps = first is
{prev}-dep, second is {this}-arr.
- Each timestamp = start of one segment; ends at the next chronological timestamp; last runs to EOF.
PA filename convention
{prev-station}-dep.mp3 / {this-station}-arr.mp3
- All lowercase, hyphens within compound names, no diacritics (Hepburn with macrons stripped).
- 1-PA mid-route stations: use
{prev}-dep (single PA covers "we just left X, next is Y").
- Terminus single PA: use
{this}-arr (only an arrival announcement at end of line).
- Compound stations:
shin-nihombashi-dep, kita-ageo-arr.
Japanese โ ASCII slug rules
Hepburn romanization, macrons stripped, lowercase, hyphens for word boundaries โ same rule used throughout the repo.
| Source | Slug |
|---|
| ๆฑไบฌ | tokyo |
| ่ถไธญๅณถ | etchujima |
| ้ซ่ผชใฒใผใใฆใงใค | takanawa-gateway |
route.json pa field
- List of basenames in playback order. Mid-route 1-PA stops typically use
[{prev}-dep]; longer-gap stops use [{prev}-dep, {this}-arr].
Tools
_dev_scripts/transcribe_pa.py โ Whisper wrapper. CUDA-enabled, downloads model on first run, outputs JSON with segment-level timestamps + text. Defaults to large-v3 on cuda. JSON output lands next to the source mp3 by default.
_dev_scripts/format_pa_timestamps.py โ formatter that converts a {station: voice_onsets} mapping into timestamps.txt with safety-margin (floor()) applied.
_dev_scripts/trim_pa_silence.py โ voice-onset detection + stream-copy trimming. Finds actual voice start (noise_floor + 12 dB attack), trims to ~80ms pad before onset. Idempotent. Replaces the old simple -40 dB gate.
_dev_scripts/validate_pa.py โ fast -40 dB silence-bracket validator. Secondary check after voice-onset trim; flags below 50ms lead/trail.
_dev_scripts/verify_pa_listen.py โ by-ear verifier. Plays first 3s of each PA segment. PASS/FAIL with notes. Covers both pa and pa_at_station entries.
Dependencies (pre-installed as dev deps)
faster-whisper โ Whisper inference.
nvidia-cublas-cu12 + nvidia-cudnn-cu12 โ CUDA runtime libs needed by faster-whisper's backend (CTranslate2). The transcribe_pa.py script handles DLL path setup automatically.
First run on a new machine downloads the ~3 GB large-v3 model into the local huggingface cache; cached after that.
Gotchas
- Discussion-first. Don't generate the splitter or update route.json until the user confirms the timestamp parse. Per-station segment count varies (1 vs 2) and surprises are common.
- CWD persists across Bash calls in this harness. Use absolute paths in verification scripts, not relative.
- First N minutes of a PA source mp3 may be non-PA filler (silence, intro). The splitter starts at the first real timestamp; everything before is discarded.
- 6-second gaps between two PA segments are real. Sometimes JR back-to-backs two announcements. Don't "fix" by merging without asking.
- Don't assume per-station PA count. The timestamps file is ground truth. Count actual entries before sizing route.json
pa arrays.
- Splitter scripts stay with their source folder (
audio_src/<line>/<diagram>/). The whole audio_src/ tree is gitignored โ splitters and timestamps live there alongside the raw mp3s, and only the cut output under audio/<line>/<diagram>/pa/ ships in the repo.
- Whisper is LLM-mediated for matching, not deterministic. Read the transcript, apply judgment for homophones and special cases โ don't write a rigid clusterer.
- Always pre-trim to the user-specified start offset before transcription. Transcribing the entire un-trimmed source pollutes context with content from preceding lines.
- Always
large-v3 for production. Medium loses bilingual content + adds homophone errors. Speed is not the bottleneck on GPU.
- The trim offset must be added back when converting Whisper output (relative to trimmed input) to original-source seconds for the splitter. The
voice_onsets in the JSON for the formatter expects absolute coordinates.
- Voice onset detection beats the -40 dB gate. Recording hiss / hum at -45 to -50 dB sits above the simple validator's threshold but is clearly not voice โ flat amplitude profile, no attack slope.
trim_pa_silence.py uses onset detection (noise_floor + 12 dB + 6 dB/30ms delta) which handles variable noise floors correctly. If validate_pa.py flags files post-trim with MISSING LEAD but onset detection passed, trust onset โ the remaining gap is noise, not voice.
- Backup before trimming.
trim_pa_silence.py modifies files in place (lossless stream-copy). Snapshot pa/ + route.json into audio_src/<line>/<diagram>/ first โ same convention as sta-make Step 7.
Documentation hook
After split + route.json update lands: if this work surfaced anything line-specific not already in audio/README.md โ IRL PA quirk (descriptive PA on a line, legacy numeric filenames, mid-route announcement style), per-line filename-convention deviation, schema-corner-case usage โ propose an entry. Decline if work was routine. (Homophone watch-list entries + pre-mamonaku special cases are NOT line-specific in the audio/README sense; they stay in this skill's Step 0.3.a / 0.3.b.)
Out of scope
- Auto-cutting timestamp clusters โ this skill keeps the matching step LLM-mediated. Don't write a deterministic clusterer; route quirks defeat it.
- STA processing โ different conventions, different tools. See sta-make skill.
- Audio normalization / loudness leveling โ runtime concern. The simulator does -15 LUFS at runtime.
Related
- sta-make skill โ STA (departure melody + closing-door) processing. Different conventions;
sta_cut placement + by-ear verification have no PA analog.
- split-audio skill โ generic continuous-source splitter for a new route. Pa-make's Step 3 splitter pattern is the PA-specific variant.
audio/README.md โ per-line IRL + sim quirks catalog (write-gate target above).
DATA_FORMAT.md โ route.json schema reference.
validate_data.py โ checks that audio files referenced by routes exist on disk.
- feedback_proactive_skill_updates.md โ reminder to update this skill inline when new lessons surface (new homophones, new special cases).