cjm-transcript-segment-align 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ """Shared chrome switching route handlers for the combined Phase 2 step"""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../../nbs/routes/chrome.ipynb.
4
+
5
+ # %% auto #0
6
+ __all__ = ['DEBUG_SWITCH_CHROME', 'init_chrome_router']
7
+
8
+ # %% ../../nbs/routes/chrome.ipynb #c3d4e5f6
9
+ from typing import Tuple, Dict, Callable
10
+
11
+ from fasthtml.common import APIRouter, Div
12
+
13
+ from cjm_fasthtml_card_stack.components.controls import render_width_slider
14
+ from cjm_fasthtml_card_stack.core.constants import DEFAULT_VISIBLE_COUNT, DEFAULT_CARD_WIDTH
15
+
16
+ from cjm_fasthtml_interactions.core.state_store import get_session_id
17
+
18
+ from cjm_workflow_state.state_store import SQLiteWorkflowStateStore
19
+
20
+ from ..html_ids import CombinedHtmlIds
21
+ from cjm_transcript_segmentation.models import TextSegment, SegmentationUrls
22
+ from cjm_transcript_vad_align.models import VADChunk, AlignmentUrls
23
+
24
+ # Segmentation renderers
25
+ from cjm_transcript_segmentation.components.step_renderer import (
26
+ render_toolbar as render_seg_toolbar,
27
+ render_seg_footer_content,
28
+ )
29
+ from cjm_transcript_segmentation.components.card_stack_config import (
30
+ SEG_CS_CONFIG, SEG_CS_IDS,
31
+ )
32
+
33
+ # Alignment renderers
34
+ from cjm_transcript_vad_align.components.step_renderer import (
35
+ render_align_toolbar, render_align_footer_content,
36
+ )
37
+ from cjm_transcript_vad_align.components.card_stack_config import (
38
+ ALIGN_CS_CONFIG, ALIGN_CS_IDS,
39
+ )
40
+
41
+ # Footer helper
42
+ from cjm_transcript_segment_align.components.step_renderer import (
43
+ render_footer_inner_content,
44
+ )
45
+
46
+ # Keyboard config
47
+ from cjm_transcript_segment_align.components.keyboard_config import (
48
+ build_combined_kb_system, render_keyboard_hints_collapsible,
49
+ )
50
+
51
+ DEBUG_SWITCH_CHROME = False
52
+
53
+ # %% ../../nbs/routes/chrome.ipynb #e5f6a7b8
54
+ async def _handle_switch_chrome(
55
+ state_store:SQLiteWorkflowStateStore, # State store instance
56
+ workflow_id:str, # Workflow identifier
57
+ request, # FastHTML request object
58
+ sess, # FastHTML session object
59
+ seg_urls:SegmentationUrls, # URL bundle for segmentation routes
60
+ align_urls:AlignmentUrls, # URL bundle for alignment routes
61
+ ) -> tuple: # OOB swaps for shared chrome containers
62
+ """Switch shared chrome content based on active column."""
63
+ form = await request.form()
64
+ active_column = form.get("active_column", "seg")
65
+
66
+ if DEBUG_SWITCH_CHROME:
67
+ print(f"[SWITCH_CHROME] active_column: {active_column}")
68
+
69
+ session_id = get_session_id(sess)
70
+ state = state_store.get_state(workflow_id, session_id)
71
+ step_states = state.get("step_states", {})
72
+ seg_state = step_states.get("segmentation", {})
73
+ align_state = step_states.get("alignment", {})
74
+
75
+ # Get counts for alignment status
76
+ segment_count = len(seg_state.get("segments", []))
77
+ chunk_count = len(align_state.get("vad_chunks", []))
78
+
79
+ # Build combined KB manager for hints
80
+ kb_manager, _ = build_combined_kb_system(seg_urls, align_urls)
81
+
82
+ if active_column == "seg":
83
+ # Segmentation chrome
84
+ segments = [TextSegment.from_dict(s) for s in seg_state.get("segments", [])]
85
+ history = seg_state.get("history", [])
86
+ focused_index = seg_state.get("focused_index", 0)
87
+ visible_count = seg_state.get("visible_count", DEFAULT_VISIBLE_COUNT)
88
+ is_auto_mode = seg_state.get("is_auto_mode", False)
89
+ card_width = seg_state.get("card_width", DEFAULT_CARD_WIDTH)
90
+
91
+ hints_content = render_keyboard_hints_collapsible(kb_manager, include_zone_switch=True)
92
+ toolbar_content = render_seg_toolbar(
93
+ reset_url=seg_urls.reset,
94
+ ai_split_url=seg_urls.ai_split,
95
+ undo_url=seg_urls.undo,
96
+ can_undo=(len(history) > 0),
97
+ visible_count=visible_count,
98
+ is_auto_mode=is_auto_mode,
99
+ )
100
+ controls_content = render_width_slider(SEG_CS_CONFIG, SEG_CS_IDS, card_width=card_width)
101
+ column_footer = render_seg_footer_content(segments, focused_index)
102
+ else:
103
+ # Alignment chrome
104
+ chunks = [VADChunk.from_dict(c) for c in align_state.get("vad_chunks", [])]
105
+ focused_index = align_state.get("focused_chunk_index", 0)
106
+ visible_count = align_state.get("visible_count", 5)
107
+ is_auto_mode = align_state.get("is_auto_mode", False)
108
+ card_width = align_state.get("card_width", 40)
109
+
110
+ hints_content = render_keyboard_hints_collapsible(kb_manager, include_zone_switch=True)
111
+ toolbar_content = render_align_toolbar(
112
+ visible_count=visible_count,
113
+ is_auto_mode=is_auto_mode,
114
+ )
115
+ controls_content = render_width_slider(ALIGN_CS_CONFIG, ALIGN_CS_IDS, card_width=card_width)
116
+ column_footer = render_align_footer_content(chunks, focused_index)
117
+
118
+ if DEBUG_SWITCH_CHROME:
119
+ print(f"[SWITCH_CHROME] returning OOB swaps for {active_column}")
120
+
121
+ # Return OOB swaps
122
+ hints_oob = Div(
123
+ hints_content,
124
+ id=CombinedHtmlIds.SHARED_HINTS,
125
+ hx_swap_oob="innerHTML"
126
+ )
127
+ toolbar_oob = Div(
128
+ toolbar_content,
129
+ id=CombinedHtmlIds.SHARED_TOOLBAR,
130
+ hx_swap_oob="innerHTML"
131
+ )
132
+ controls_oob = Div(
133
+ controls_content,
134
+ id=CombinedHtmlIds.SHARED_CONTROLS,
135
+ hx_swap_oob="innerHTML"
136
+ )
137
+ footer_oob = Div(
138
+ render_footer_inner_content(column_footer, segment_count, chunk_count),
139
+ id=CombinedHtmlIds.SHARED_FOOTER,
140
+ hx_swap_oob="innerHTML"
141
+ )
142
+
143
+ return (hints_oob, toolbar_oob, controls_oob, footer_oob)
144
+
145
+ # %% ../../nbs/routes/chrome.ipynb #g7b8c9d0
146
+ def init_chrome_router(
147
+ state_store: SQLiteWorkflowStateStore, # State store instance
148
+ workflow_id: str, # Workflow identifier
149
+ seg_urls: SegmentationUrls, # URL bundle for segmentation routes
150
+ align_urls: AlignmentUrls, # URL bundle for alignment routes
151
+ prefix: str, # Route prefix (e.g., "/workflow/core/chrome")
152
+ ) -> Tuple[APIRouter, Dict[str, Callable]]: # (router, route_dict)
153
+ """Initialize chrome switching routes."""
154
+ router = APIRouter(prefix=prefix)
155
+
156
+ @router
157
+ async def switch_chrome(request, sess):
158
+ """Switch shared chrome content based on active column."""
159
+ return await _handle_switch_chrome(
160
+ state_store, workflow_id, request, sess,
161
+ seg_urls=seg_urls,
162
+ align_urls=align_urls,
163
+ )
164
+
165
+ routes = {
166
+ "switch_chrome": switch_chrome,
167
+ }
168
+
169
+ return router, routes
@@ -0,0 +1,396 @@
1
+ """Routes for triggering forced alignment, polling progress, and toggling between NLTK and force-aligned pre-splits"""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../../nbs/routes/forced_alignment.ipynb.
4
+
5
+ # %% auto #0
6
+ __all__ = ['FA_CONTAINER_ID', 'FA_STATUS_ID', 'render_fa_trigger_button', 'render_fa_progress', 'render_fa_toggle',
7
+ 'render_fa_controls', 'init_forced_alignment_routers']
8
+
9
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-imports
10
+ from typing import Any, Dict, List, Tuple, Callable, Optional
11
+ from dataclasses import asdict
12
+
13
+ from fasthtml.common import Div, Span, Button, Progress, APIRouter
14
+
15
+ from cjm_fasthtml_interactions.core.state_store import get_session_id
16
+ from cjm_fasthtml_daisyui.components.actions.button import btn, btn_colors, btn_sizes, btn_styles
17
+ from cjm_fasthtml_daisyui.components.feedback.loading import loading, loading_styles, loading_sizes
18
+ from cjm_fasthtml_daisyui.components.feedback.progress import progress as progress_cls, progress_colors
19
+ from cjm_fasthtml_daisyui.components.layout.join import join, join_item
20
+ from cjm_fasthtml_daisyui.components.data_display.badge import badge, badge_colors, badge_sizes
21
+ from cjm_fasthtml_tailwind.utilities.spacing import p, m
22
+ from cjm_fasthtml_tailwind.utilities.typography import font_size, font_weight
23
+ from cjm_fasthtml_tailwind.utilities.flexbox_and_grid import flex_display, items, gap, justify
24
+ from cjm_fasthtml_tailwind.core.base import combine_classes
25
+ from cjm_fasthtml_lucide_icons.factory import lucide_icon
26
+
27
+ from cjm_workflow_state.state_store import SQLiteWorkflowStateStore
28
+ from cjm_transcript_segmentation.models import TextSegment, SegmentationUrls
29
+ from cjm_transcript_segmentation.components.step_renderer import render_seg_column_body
30
+ from cjm_transcript_segmentation.components.card_stack_config import SEG_CS_CONFIG, SEG_CS_IDS
31
+ from cjm_transcript_vad_align.models import VADChunk
32
+ from cjm_transcript_source_select.services.source import SourceService
33
+
34
+ from ..html_ids import CombinedHtmlIds
35
+ from cjm_transcript_segment_align.components.step_renderer import (
36
+ render_alignment_status, render_seg_mini_stats_badge,
37
+ )
38
+ from ..services.forced_alignment import ForcedAlignmentService
39
+
40
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-ids
41
+ # Stable HTML IDs for forced alignment UI elements
42
+ FA_CONTAINER_ID = "sd-fa-controls"
43
+ FA_STATUS_ID = "sd-fa-status"
44
+
45
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-render-button
46
+ def render_fa_trigger_button(
47
+ trigger_url: str, # URL for forced alignment trigger route
48
+ disabled: bool = False, # Whether button is disabled
49
+ ) -> Any: # Force Align trigger button
50
+ """Render the Force Align trigger button."""
51
+ return Button(
52
+ lucide_icon("audio-waveform", size=4, cls=str(m.r(2))),
53
+ "Force Align",
54
+ cls=combine_classes(btn, btn_colors.primary, btn_sizes.sm),
55
+ disabled="disabled" if disabled else None,
56
+ hx_post=trigger_url,
57
+ hx_swap="none",
58
+ )
59
+
60
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-render-progress
61
+ def render_fa_progress(
62
+ progress_val: float, # Progress value 0.0-1.0
63
+ message: str, # Progress stage message
64
+ progress_url: str, # URL for progress polling
65
+ ) -> Any: # Progress indicator with polling
66
+ """Render forced alignment progress indicator with HTMX polling."""
67
+ pct = int(progress_val * 100)
68
+ return Div(
69
+ Div(
70
+ Span(cls=combine_classes(loading, loading_styles.spinner, loading_sizes.sm)),
71
+ Span(
72
+ f"{message} ({pct}%)",
73
+ cls=combine_classes(font_size.sm, m.l(2))
74
+ ),
75
+ cls=combine_classes(flex_display, items.center)
76
+ ),
77
+ Progress(
78
+ value=str(pct), max="100",
79
+ cls=combine_classes(progress_cls, progress_colors.primary, "w-48")
80
+ ),
81
+ hx_get=progress_url,
82
+ hx_trigger="every 1s",
83
+ hx_target=f"#{FA_CONTAINER_ID}",
84
+ hx_swap="innerHTML",
85
+ cls=combine_classes(flex_display, items.center, gap(3)),
86
+ )
87
+
88
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-render-toggle
89
+ def render_fa_toggle(
90
+ active_presplit: str, # "nltk" or "forced_alignment"
91
+ toggle_url: str, # URL for toggle route
92
+ ) -> Any: # Toggle button group
93
+ """Render the NLTK / Force Aligned toggle button group."""
94
+ nltk_active = active_presplit == "nltk"
95
+ fa_active = active_presplit == "forced_alignment"
96
+
97
+ nltk_cls = combine_classes(
98
+ btn, btn_sizes.sm, join_item,
99
+ btn_colors.primary if nltk_active else btn_styles.outline
100
+ )
101
+ fa_cls = combine_classes(
102
+ btn, btn_sizes.sm, join_item,
103
+ btn_colors.primary if fa_active else btn_styles.outline
104
+ )
105
+
106
+ return Div(
107
+ Span("Pre-splits:", cls=combine_classes(font_size.sm, m.r(2))),
108
+ Div(
109
+ Button(
110
+ lucide_icon("text-cursor-input", size=4, cls=str(m.r(1))),
111
+ "NLTK",
112
+ cls=nltk_cls,
113
+ hx_post=toggle_url,
114
+ hx_vals='{"mode": "nltk"}',
115
+ hx_swap="none",
116
+ disabled="disabled" if nltk_active else None,
117
+ ),
118
+ Button(
119
+ lucide_icon("audio-waveform", size=4, cls=str(m.r(1))),
120
+ "Force Aligned",
121
+ cls=fa_cls,
122
+ hx_post=toggle_url,
123
+ hx_vals='{"mode": "forced_alignment"}',
124
+ hx_swap="none",
125
+ disabled="disabled" if fa_active else None,
126
+ ),
127
+ cls=join,
128
+ ),
129
+ cls=combine_classes(flex_display, items.center),
130
+ )
131
+
132
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-render-container
133
+ def render_fa_controls(
134
+ trigger_url: str = "", # URL for trigger route
135
+ toggle_url: str = "", # URL for toggle route
136
+ active_presplit: Optional[str] = None, # Current active mode (None = no FA done yet)
137
+ fa_available: bool = False, # Whether FA plugin is available
138
+ oob: bool = False, # Whether to render as OOB swap
139
+ ) -> Any: # FA controls container
140
+ """Render the forced alignment controls container.
141
+
142
+ Shows either:
143
+ - Trigger button (if FA not yet run)
144
+ - Toggle (if FA has been run)
145
+ - Nothing (if FA plugin not available)
146
+ """
147
+ if not fa_available:
148
+ content = None
149
+ elif active_presplit is not None:
150
+ # FA has been run — show toggle
151
+ content = render_fa_toggle(active_presplit, toggle_url)
152
+ else:
153
+ # FA available but not yet run — show trigger button
154
+ content = render_fa_trigger_button(trigger_url)
155
+
156
+ return Div(
157
+ content,
158
+ id=FA_CONTAINER_ID,
159
+ cls=combine_classes(flex_display, items.center, gap(2)),
160
+ hx_swap_oob="true" if oob else None,
161
+ )
162
+
163
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-handler-trigger
164
+ async def _handle_fa_trigger(
165
+ state_store: SQLiteWorkflowStateStore,
166
+ workflow_id: str,
167
+ fa_service: ForcedAlignmentService,
168
+ source_service: SourceService,
169
+ request: Any,
170
+ sess: Any,
171
+ seg_urls: SegmentationUrls,
172
+ progress_url: str,
173
+ toggle_url: str,
174
+ ) -> Any: # OOB updates for card stack, alignment status, FA controls, mini-stats
175
+ """Trigger forced alignment and replace working segments."""
176
+ session_id = get_session_id(sess)
177
+ state = state_store.get_state(workflow_id, session_id)
178
+ step_states = state.get("step_states", {})
179
+ seg_state = step_states.get("segmentation", {})
180
+ align_state = step_states.get("alignment", {})
181
+ selection_state = step_states.get("selection", {})
182
+
183
+ # Get VAD chunks (must be initialized)
184
+ vad_chunk_dicts = align_state.get("vad_chunks", [])
185
+ if not vad_chunk_dicts:
186
+ return render_fa_controls(
187
+ trigger_url="", fa_available=True, oob=True
188
+ )
189
+
190
+ vad_chunks = [VADChunk.from_dict(c) for c in vad_chunk_dicts]
191
+
192
+ # Get source info for audio paths and text
193
+ selected_sources = selection_state.get("selected_sources", [])
194
+ source_blocks = source_service.get_source_blocks(selected_sources)
195
+
196
+ # Build audio paths from source blocks
197
+ audio_paths = []
198
+ for src in selected_sources:
199
+ block = source_service.get_transcription_by_id(src["record_id"], src["provider_id"])
200
+ audio_paths.append(block.media_path)
201
+
202
+ # Group VAD chunks by source (using audio_file_index if available)
203
+ if len(source_blocks) == 1:
204
+ vad_chunks_by_source = [vad_chunks]
205
+ else:
206
+ chunks_by_idx: Dict[int, List[VADChunk]] = {}
207
+ for chunk in vad_chunks:
208
+ idx = getattr(chunk, 'audio_file_index', 0)
209
+ if idx not in chunks_by_idx:
210
+ chunks_by_idx[idx] = []
211
+ chunks_by_idx[idx].append(chunk)
212
+ vad_chunks_by_source = [chunks_by_idx.get(i, []) for i in range(len(source_blocks))]
213
+
214
+ # Run forced alignment
215
+ fa_segments = await fa_service.align_and_split_combined_async(
216
+ source_blocks=source_blocks,
217
+ audio_paths=audio_paths,
218
+ vad_chunks_by_source=vad_chunks_by_source,
219
+ )
220
+
221
+ # Save NLTK segments before replacing (preserve for toggle)
222
+ if "nltk_segments" not in seg_state:
223
+ seg_state["nltk_segments"] = seg_state.get("segments", [])
224
+ seg_state["nltk_initial_segments"] = seg_state.get("initial_segments", [])
225
+
226
+ # Store FA segments and set as active
227
+ fa_seg_dicts = [asdict(s) for s in fa_segments]
228
+ seg_state["fa_segments"] = fa_seg_dicts
229
+ seg_state["fa_initial_segments"] = fa_seg_dicts[:]
230
+ seg_state["segments"] = fa_seg_dicts
231
+ seg_state["initial_segments"] = fa_seg_dicts[:]
232
+ seg_state["active_presplit"] = "forced_alignment"
233
+ seg_state["focused_index"] = 0
234
+ seg_state["history"] = []
235
+
236
+ step_states["segmentation"] = seg_state
237
+ state["step_states"] = step_states
238
+ state_store.update_state(workflow_id, session_id, state)
239
+
240
+ # Re-render card stack and add OOB outerHTML for column body swap
241
+ column_body = render_seg_column_body(
242
+ segments=fa_segments,
243
+ focused_index=0,
244
+ visible_count=seg_state.get("visible_count", 3),
245
+ card_width=seg_state.get("card_width", 80),
246
+ urls=seg_urls,
247
+ kb_system=None,
248
+ )
249
+ column_body.attrs['hx-swap-oob'] = 'outerHTML'
250
+
251
+ # Build OOB updates
252
+ segment_count = len(fa_segments)
253
+ chunk_count = len(vad_chunk_dicts)
254
+
255
+ alignment_status_oob = render_alignment_status(segment_count, chunk_count, oob=True)
256
+ mini_stats_oob = render_seg_mini_stats_badge(fa_segments, oob=True)
257
+ fa_controls_oob = render_fa_controls(
258
+ toggle_url=toggle_url,
259
+ active_presplit="forced_alignment",
260
+ fa_available=True,
261
+ oob=True,
262
+ )
263
+
264
+ return (column_body, alignment_status_oob, mini_stats_oob, fa_controls_oob)
265
+
266
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-handler-toggle
267
+ async def _handle_fa_toggle(
268
+ state_store: SQLiteWorkflowStateStore,
269
+ workflow_id: str,
270
+ request: Any,
271
+ sess: Any,
272
+ seg_urls: SegmentationUrls,
273
+ toggle_url: str,
274
+ ) -> Any: # OOB updates for card stack, alignment status, FA controls, mini-stats
275
+ """Toggle between NLTK and force-aligned pre-splits."""
276
+ form = await request.form()
277
+ target_mode = form.get("mode", "nltk")
278
+
279
+ session_id = get_session_id(sess)
280
+ state = state_store.get_state(workflow_id, session_id)
281
+ step_states = state.get("step_states", {})
282
+ seg_state = step_states.get("segmentation", {})
283
+ align_state = step_states.get("alignment", {})
284
+
285
+ current_mode = seg_state.get("active_presplit", "nltk")
286
+
287
+ if target_mode == current_mode:
288
+ return # No-op
289
+
290
+ # Save current working segments to the current mode's storage
291
+ if current_mode == "nltk":
292
+ seg_state["nltk_segments"] = seg_state.get("segments", [])
293
+ else:
294
+ seg_state["fa_segments"] = seg_state.get("segments", [])
295
+
296
+ # Restore target mode's segments
297
+ if target_mode == "nltk":
298
+ seg_state["segments"] = seg_state.get("nltk_segments", seg_state.get("initial_segments", []))
299
+ seg_state["initial_segments"] = seg_state.get("nltk_initial_segments", seg_state.get("initial_segments", []))
300
+ else:
301
+ seg_state["segments"] = seg_state.get("fa_segments", [])
302
+ seg_state["initial_segments"] = seg_state.get("fa_initial_segments", [])
303
+
304
+ seg_state["active_presplit"] = target_mode
305
+ seg_state["focused_index"] = 0
306
+ seg_state["history"] = []
307
+
308
+ step_states["segmentation"] = seg_state
309
+ state["step_states"] = step_states
310
+ state_store.update_state(workflow_id, session_id, state)
311
+
312
+ # Re-render card stack with new segments and add OOB outerHTML
313
+ segments = [TextSegment.from_dict(s) for s in seg_state["segments"]]
314
+
315
+ column_body = render_seg_column_body(
316
+ segments=segments,
317
+ focused_index=0,
318
+ visible_count=seg_state.get("visible_count", 3),
319
+ card_width=seg_state.get("card_width", 80),
320
+ urls=seg_urls,
321
+ kb_system=None,
322
+ )
323
+ column_body.attrs['hx-swap-oob'] = 'outerHTML'
324
+
325
+ # Build OOB updates
326
+ chunk_count = len(align_state.get("vad_chunks", []))
327
+
328
+ alignment_status_oob = render_alignment_status(len(segments), chunk_count, oob=True)
329
+ mini_stats_oob = render_seg_mini_stats_badge(segments, oob=True)
330
+ fa_controls_oob = render_fa_controls(
331
+ toggle_url=toggle_url,
332
+ active_presplit=target_mode,
333
+ fa_available=True,
334
+ oob=True,
335
+ )
336
+
337
+ return (column_body, alignment_status_oob, mini_stats_oob, fa_controls_oob)
338
+
339
+ # %% ../../nbs/routes/forced_alignment.ipynb #cell-router
340
+ def init_forced_alignment_routers(
341
+ state_store: SQLiteWorkflowStateStore, # State store instance
342
+ workflow_id: str, # Workflow identifier
343
+ fa_service: ForcedAlignmentService, # Forced alignment service
344
+ source_service: SourceService, # Source service for audio paths/text
345
+ seg_urls: SegmentationUrls, # Segmentation URL bundle
346
+ prefix: str, # Route prefix (e.g., "/fa")
347
+ ) -> Tuple[APIRouter, Dict[str, Callable]]: # (router, route_dict)
348
+ """Initialize forced alignment routes."""
349
+ router = APIRouter(prefix=prefix)
350
+
351
+ @router
352
+ async def trigger(request, sess):
353
+ """Trigger forced alignment."""
354
+ return await _handle_fa_trigger(
355
+ state_store, workflow_id, fa_service, source_service,
356
+ request, sess, seg_urls,
357
+ progress_url=progress.to(),
358
+ toggle_url=toggle.to(),
359
+ )
360
+
361
+ @router
362
+ async def progress(request, sess):
363
+ """Poll forced alignment progress."""
364
+ # Check if FA is still running by checking plugin progress
365
+ proxy = fa_service._manager.get_plugin(fa_service._plugin_name)
366
+ if proxy:
367
+ try:
368
+ prog = await proxy.get_progress_async()
369
+ if prog and prog.get("progress", 0) < 1.0:
370
+ return render_fa_progress(
371
+ progress_val=prog["progress"],
372
+ message=prog.get("message", "Processing..."),
373
+ progress_url=progress.to(),
374
+ )
375
+ except Exception:
376
+ pass
377
+
378
+ # FA is done or not running — show trigger button (will be replaced by toggle after trigger completes)
379
+ return render_fa_trigger_button(trigger.to())
380
+
381
+ @router
382
+ async def toggle(request, sess):
383
+ """Toggle between NLTK and force-aligned pre-splits."""
384
+ return await _handle_fa_toggle(
385
+ state_store, workflow_id,
386
+ request, sess, seg_urls,
387
+ toggle_url=toggle.to(),
388
+ )
389
+
390
+ routes = {
391
+ "trigger": trigger,
392
+ "progress": progress,
393
+ "toggle": toggle,
394
+ }
395
+
396
+ return router, routes
File without changes