Inspect forced alignments of (learner) speech on a waveform + spectrogram, drag exactly the span that is wrong, fix its words, and re-align only that span with the Montreal Forced Aligner — with every correction recorded on a provenance tier and exportable as Praat TextGrid or JSON.
- Overview
- Features
- Quick start
- Installation
- Using the app
- Export formats
- Saving corrections
- Configuration
- Project layout
- Troubleshooting
- Changelog
Forced aligners are reliable on fluent read speech and unreliable on hesitant, pause-heavy learner speech: a single mis-transcribed or mis-placed stretch can drag a whole utterance out of place. Re-running the aligner on the entire file with a corrected transcript often reproduces the same drift.
This app takes a detect-then-correct approach. You look at the alignment on the signal, select exactly the
region that is wrong, type what is actually said there, and the aligner runs on that clip only — anchored to the
boundaries you chose, not to the boundaries the previous (wrong) alignment produced. The result is spliced back into
the file's tiers, the correction is logged on an edits tier (old words → new words), and the corrected layer can
be saved server-side or downloaded.
It works on a built-in example set (the NIAOpic synthetic-mismatch corpus, when available) or on any wav you upload, optionally with an existing TextGrid, and can transcribe uploads with CrisperWhisper.
| Signal view | Waveform + mel spectrogram in one scroll/zoom container (up to 480 px/s); words in the top lane, phones in the bottom lane as clickable regions; drag-to-select with Zoom to span / Play once / Loop. |
| Local correction | Drag the wrong region → edit its words → ⟳ Segment re-align. MFA aligns only that clip (wide beam), the result is spliced into the shown layer, the main transcript is updated, and the span is recorded on the edits tier. |
| Whole-file re-align | ⟳ MFA re-align from the edited transcript, exp3-style: VAD-segmented, per-segment MFA, speaker-adapted model when available. |
| Transcription | 🎙 Transcribe (CrisperWhisper) for uploaded wavs — verbatim, fillers kept as [UM]/[UH] (→ spn). |
| Inputs | Built-in examples, or your own wavs: single files, multi-select, or a whole folder; optional wav + TextGrid pairs matched by basename. Runs without the corpus tree (uploads-only mode). |
| Persistence & export | 💾 Save realigned keeps the corrected layer on the server (restored as the file's default view); ⬇ TextGrid (Praat, 3 tiers) and ⬇ JSON downloads. |
| UI | Light / dark theme (remembered per browser), English labels, unsaved-change guards. |
git clone git@github.com:exphon/local_realign.git && cd local_realign
pip install -r requirements.txt # server python: numpy + torch (CPU is fine)
conda create -n mfa -c conda-forge python=3.10 montreal-forced-aligner=3.3.3
conda run -n mfa mfa model download acoustic english_us_arpa
conda run -n mfa mfa model download dictionary english_us_arpa
./run.sh # builds the frontend, serves UI + API on http://0.0.0.0:8000Open http://<host>:8000 from any machine on the network. Hot-reload development mode
(Vite on :5173 proxying to the backend on :8000): ./run.sh dev.
run.shusespythonwhen available (including an activated environment), otherwisepython3. Python 3.10+ is required. To select an interpreter explicitly, usePYTHON=/path/to/python ./run.sh; installrequirements.txtinto that interpreter if you need Silero VAD. SetPORT=8001 ./run.sh(orPORT=8001 ./run.sh dev) to use a different backend port.
Requirements are split by the process that needs them; each file documents its own install.
| File | Used by | Install |
|---|---|---|
requirements.txt |
the server's Python — numpy, torch (Silero VAD only) |
pip install -r requirements.txt — CPU-only torch is enough: add --index-url https://download.pytorch.org/whl/cpu |
requirements-mfa.txt |
conda env mfa — Montreal Forced Aligner (conda-forge; not pip-installable) |
conda create -n mfa -c conda-forge python=3.10 montreal-forced-aligner=3.3.3, then mfa model download acoustic english_us_arpa and … dictionary english_us_arpa |
requirements-crisperwhisper.txt |
project env .venv-crisperwhisper — optional, only for 🎙 Transcribe |
./setup-crisperwhisper.sh; official CrisperWhisper 2.0 with the Transformers backend for macOS, CPU or CUDA |
frontend/package.json |
Node ≥ 18 + npm | run.sh runs npm install and npm run build |
Without torch the server still runs; whole-file re-align then skips the VAD split and aligns each file as one segment. Without the CrisperWhisper env everything except the Transcribe button works.
./setup-crisperwhisper.sh # uses uv + Python 3.12 when available, otherwise python3 + venv
./run.shUpload a WAV and click 🎙 Transcribe (CrisperWhisper 2.0). The default model is
nyralabs/CrisperWhisper2.0_medium, with English verbatim transcription. Fillers,
repetitions and false starts are kept. Audio longer than 30 seconds uses the official
continuation algorithm; the app no longer splits audio and deletes repeated words itself.
The transcript fills the editor for the existing MFA re-alignment workflow.
The backend automatically uses .venv-crisperwhisper/bin/python; conda is not required
for transcription. The first transcription downloads model weights from Hugging Face
and subsequent runs reuse its cache. HF_TOKEN or a cached Hugging Face login is used
when available, but the app does not require a token before trying to load the model.
Inference runs locally. Device selection is CUDA → Apple MPS → CPU; MPS and CPU use
float32 by default. To force CPU or change model/language:
CRISPER_DEVICE=cpu ./run.sh
CRISPER_MODEL=large CRISPER_LANGUAGE=ko ./run.sh
# A separate interpreter or existing conda environment can also be selected:
CRISPER_PYTHON=/path/to/python ./run.sh
CONDA_BIN=/path/to/conda CRISPER_ENV=crisperWhisper ./run.shThese environments need the current requirements-crisperwhisper.txt; the old v1
environment with Transformers 4.37.2 is not sufficient. Transcription jobs allow up to
30 minutes for model loading and inference. Direct CLI use is also available:
.venv-crisperwhisper/bin/python backend/transcribe_wrapper.py --f recording.wavThe integration uses crisperwhisper 2.0.3
with Transformers 4.57.6 pinned for long-audio compatibility (Transformers 5.19.0
failed in the upstream attention/cache path during local testing).
See the official documentation
for model capabilities. The model weights have a separate license from the inference code;
see the medium model license.
- Example list (top) — the built-in synthetic-mismatch set, filterable by proficiency level. Empty in uploads-only mode.
- My files (uploads) — ⬆ Select files (one or many wavs) or 📁 Folder. 16-bit PCM wav only. If the
selection also contains
.TextGridfiles, each one whose basename matches a wav is attached as that file'suploadedlayer. Files with a saved correction show 💾. - ☀ Light / ☾ Dark — theme toggle (persisted in
localStorage; first visit follows the OS preference).
- Layer —
Original exp3 (whisperx)·Uploaded TextGrid·Realigned(radio; unavailable layers are greyed). Default: the savedrealignedlayer if any, elseuploaded, elseexp3. - ⬇ TextGrid / ⬇ JSON — download the shown layer (see Export formats).
- 💾 Save realigned / 🗑 — persist or delete the corrected layer (see Saving corrections).
- Words / Phones / Spectrogram — overlay toggles. The
editstier is always drawn when present.
- Waveform and spectrogram scroll and zoom together; Zoom slider up to 480 px/s.
- Click a word or phone region to play just that interval.
- Drag on the waveform or spectrogram to select a span; the selection bar offers 🔍 Zoom to span, ▶ Play once, 🔁 Loop, ✕ Clear.
- Edit transcript → re-align — the whole-file transcript. ⟳ MFA re-align re-aligns the file from it (uploads: 🎙 Transcribe (CrisperWhisper) fills it first).
- Realign selection [t0–t1 s] — appears after a drag, prefilled with the words that overlap the selection. Edit them and press ⟳ Segment re-align.
- Pick a file (or upload wavs and transcribe them).
- Listen and find a mis-aligned stretch on the waveform / spectrogram.
- Drag from its true start to its true end — ignore the current word boxes; they may be what is wrong —
fix the words in Realign selection, ⟳ Segment re-align. Repeat for other spans; each is appended to the
editstier. - 💾 Save realigned, and/or download ⬇ TextGrid / ⬇ JSON.
Both buttons export the currently shown layer as <file>.<layer>.<ext>.
| Button | File | Built by | Content |
|---|---|---|---|
| ⬇ TextGrid | <file>.<layer>.TextGrid |
server, POST /api/export_textgrid |
Praat long format; interval tiers words, phones, and edits when present; gaps filled with empty intervals, overlaps resolved; xmax = wav duration. Opens directly in Praat. |
| ⬇ JSON | <file>.<layer>.json |
client, downloadJson() |
Labelled intervals only (no gap fillers). |
JSON layout:
{
"format": "local_realign.tiers/1",
"exported_at": "2026-09-21T09:12:33.120Z",
"uid": "ESPEAK_2192_IG_STUD_F_CC_09__del31",
"name": "ESPEAK_2192_IG_STUD_F_CC_09",
"layer": "realigned",
"saved_at": "2026-09-21T18:11:02+09:00",
"xmax": 43.64,
"tiers": {
"words": [{"start": 0.065, "end": 0.153, "text": "will"}, "…"],
"phones": ["…"],
"edits": [{"start": 23.30, "end": 30.94,
"text": "channels → explain your situation to the receptionist"}]
}
}saved_at is set when the exported realigned layer equals the saved one, otherwise null.
Generated files are automatically written next to the WAV the server processes:
- CrisperWhisper completion writes
<wav-stem>.txt(UTF-8, original transcription). - Whole-file or segment MFA completion writes
<wav-stem>.TextGrid. Segment alignment saves the complete merged timeline, including theeditstier, rather than only the clip. - Repeating an operation replaces its output file. Cached MFA results also recreate the file. Files are replaced atomically; a write failure is reported instead of claiming success.
- For browser uploads the WAV is a server copy, so these files appear in
backend/uploads/beside<uid>.wav. The browser does not provide the original local folder path. The completion message shows the actual output path.
The app's separately saved layer is a snapshot restored when reopening a file:
- 💾 Save realigned posts the current
realignedtiers to/api/save_realigned, which writesbackend/saved/<uid>.TextGrid(canonical, Praat-openable) and<uid>.json(exact tiers + timestamp), atomically. It also updates the TextGrid beside the WAV. - Afterwards
GET /api/meta/<uid>reportssaved/saved_atand listsrealignedamong the available layers, andGET /api/textgrid/<uid>?which=realignedserves it — for built-in examples and uploads alike. Reloading the page or restarting the server restores the layer as the file's default view; the sidebar marks the file with 💾. - The button reads 💾 Save realigned ● while the in-memory layer differs from the saved one and 💾 Saved once they match. Switching files or closing the tab with unsaved changes asks for confirmation.
- 🗑 deletes the app's saved layer (with confirmation); the in-memory layer is kept and marked unsaved. Generated files beside the WAV are retained.
backend/saved/is git-ignored — back it up with your data.
backend/
server.py stdlib HTTP server + API; MFA / CrisperWhisper via `conda run`
transcribe_wrapper.py CrisperWhisper 2.0 CLI wrapper (project venv or configured runtime)
uploads/, saved/ uploaded wavs (+ index.json), saved realigned layers [git-ignored]
frontend/
src/App.jsx sidebar, layers, transcript editor, span editor, save / export
src/components/Player.jsx wavesurfer viewer: regions, selection, theme colours
src/api.js, src/styles.css
docs/ screenshots used in this README
paper_figures/ figures used in the accompanying paper
run.sh build the frontend and serve everything on :8000
requirements*.txt server python / mfa env / crisperWhisper env
| Symptom | Cause / fix |
|---|---|
| A button described here is missing | the browser tab holds a stale bundle — hard-reload (Ctrl+Shift+R) |
MFA produced no TextGrid for the clip (… words: "…") |
the dragged span is shorter than the words entered, or the words are not what is said there; widen the span or fix the words |
Result looks "squeezed", status warns > 5 words/s |
same as above — the span was too short for the transcript |
| CrisperWhisper dependencies missing / runtime not found | run ./setup-crisperwhisper.sh; check CRISPER_PYTHON / CRISPER_ENV overrides |
| CrisperWhisper model download fails | check network access; for a restricted model, verify Hugging Face access and HF_TOKEN |
| CrisperWhisper MPS error or insufficient memory | restart with CRISPER_DEVICE=cpu ./run.sh or select CRISPER_MODEL=small |
| MFA / conda executable not found | existing conda environments are detected automatically; for a custom installation use MFA_BIN=/path/to/env/bin/mfa ./run.sh (its bin directory is also added to the MFA subprocess PATH) |
| Example list is empty | NIAOPIC_ROOT does not point at the corpus tree — expected in uploads-only mode |
| Re-align aligns each file as one segment | torch is not installed in the server's Python (Silero VAD unavailable) |
2026-09-22 — README redesign; screenshots in docs/.
2026-09-21 — edits provenance tier for re-aligned spans (drawn on the waveform, included in TextGrid/JSON
exports and saved layers); ⬇ JSON export; export names keep the layer suffix; duration in /api/meta.
2026-09-18 — Portability: requirements*.txt; server runs without the corpus tree (NIAOPIC_ROOT,
uploads-only mode); Silero VAD torch.hub fallback; bundled transcribe_wrapper.py.
2026-09-15 — 💾 Save realigned / 🗑 (/api/save_realigned, /api/discard_saved), saved layer restored
as default view, unsaved-change guards; span edits propagate to the main transcript.
2026-09-14 — Light / dark theme; English UI; title renamed; layer menu reduced to exp3 / uploaded /
realigned; GT / ASR overlays and transcript-fill buttons removed from the UI.
2026-09-11 — Span re-align uses the dragged region exactly (no snapping); straddling intervals are clipped; MFA beam 100 / 400 for spans; status reports words / clip seconds and warns above 5 words/s.
