karaoke-gen 0.57.0__py3-none-any.whl → 0.71.23__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.
Files changed (268) hide show
  1. karaoke_gen/audio_fetcher.py +461 -0
  2. karaoke_gen/audio_processor.py +407 -30
  3. karaoke_gen/config.py +62 -113
  4. karaoke_gen/file_handler.py +32 -59
  5. karaoke_gen/karaoke_finalise/karaoke_finalise.py +148 -67
  6. karaoke_gen/karaoke_gen.py +270 -61
  7. karaoke_gen/lyrics_processor.py +13 -1
  8. karaoke_gen/metadata.py +78 -73
  9. karaoke_gen/pipeline/__init__.py +87 -0
  10. karaoke_gen/pipeline/base.py +215 -0
  11. karaoke_gen/pipeline/context.py +230 -0
  12. karaoke_gen/pipeline/executors/__init__.py +21 -0
  13. karaoke_gen/pipeline/executors/local.py +159 -0
  14. karaoke_gen/pipeline/executors/remote.py +257 -0
  15. karaoke_gen/pipeline/stages/__init__.py +27 -0
  16. karaoke_gen/pipeline/stages/finalize.py +202 -0
  17. karaoke_gen/pipeline/stages/render.py +165 -0
  18. karaoke_gen/pipeline/stages/screens.py +139 -0
  19. karaoke_gen/pipeline/stages/separation.py +191 -0
  20. karaoke_gen/pipeline/stages/transcription.py +191 -0
  21. karaoke_gen/style_loader.py +531 -0
  22. karaoke_gen/utils/bulk_cli.py +6 -0
  23. karaoke_gen/utils/cli_args.py +424 -0
  24. karaoke_gen/utils/gen_cli.py +26 -261
  25. karaoke_gen/utils/remote_cli.py +1815 -0
  26. karaoke_gen/video_background_processor.py +351 -0
  27. karaoke_gen-0.71.23.dist-info/METADATA +610 -0
  28. karaoke_gen-0.71.23.dist-info/RECORD +275 -0
  29. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info}/WHEEL +1 -1
  30. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info}/entry_points.txt +1 -0
  31. lyrics_transcriber/__init__.py +10 -0
  32. lyrics_transcriber/cli/__init__.py +0 -0
  33. lyrics_transcriber/cli/cli_main.py +285 -0
  34. lyrics_transcriber/core/__init__.py +0 -0
  35. lyrics_transcriber/core/config.py +50 -0
  36. lyrics_transcriber/core/controller.py +520 -0
  37. lyrics_transcriber/correction/__init__.py +0 -0
  38. lyrics_transcriber/correction/agentic/__init__.py +9 -0
  39. lyrics_transcriber/correction/agentic/adapter.py +71 -0
  40. lyrics_transcriber/correction/agentic/agent.py +313 -0
  41. lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
  42. lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
  43. lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
  44. lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
  45. lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
  46. lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
  47. lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
  48. lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
  49. lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
  50. lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
  51. lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
  52. lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
  53. lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
  54. lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
  55. lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
  56. lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
  57. lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
  58. lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
  59. lyrics_transcriber/correction/agentic/models/enums.py +38 -0
  60. lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
  61. lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
  62. lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
  63. lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
  64. lyrics_transcriber/correction/agentic/models/utils.py +19 -0
  65. lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
  66. lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
  67. lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
  68. lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
  69. lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
  70. lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
  71. lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
  72. lyrics_transcriber/correction/agentic/providers/base.py +36 -0
  73. lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
  74. lyrics_transcriber/correction/agentic/providers/config.py +73 -0
  75. lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
  76. lyrics_transcriber/correction/agentic/providers/health.py +28 -0
  77. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
  78. lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
  79. lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
  80. lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
  81. lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
  82. lyrics_transcriber/correction/agentic/router.py +35 -0
  83. lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
  84. lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
  85. lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
  86. lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
  87. lyrics_transcriber/correction/anchor_sequence.py +1043 -0
  88. lyrics_transcriber/correction/corrector.py +760 -0
  89. lyrics_transcriber/correction/feedback/__init__.py +2 -0
  90. lyrics_transcriber/correction/feedback/schemas.py +107 -0
  91. lyrics_transcriber/correction/feedback/store.py +236 -0
  92. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  93. lyrics_transcriber/correction/handlers/base.py +52 -0
  94. lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
  95. lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
  96. lyrics_transcriber/correction/handlers/llm.py +293 -0
  97. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  98. lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
  99. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
  100. lyrics_transcriber/correction/handlers/repeat.py +88 -0
  101. lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
  102. lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
  103. lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
  104. lyrics_transcriber/correction/handlers/word_operations.py +187 -0
  105. lyrics_transcriber/correction/operations.py +352 -0
  106. lyrics_transcriber/correction/phrase_analyzer.py +435 -0
  107. lyrics_transcriber/correction/text_utils.py +30 -0
  108. lyrics_transcriber/frontend/.gitignore +23 -0
  109. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
  110. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  111. lyrics_transcriber/frontend/README.md +50 -0
  112. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  113. lyrics_transcriber/frontend/__init__.py +25 -0
  114. lyrics_transcriber/frontend/eslint.config.js +28 -0
  115. lyrics_transcriber/frontend/index.html +18 -0
  116. lyrics_transcriber/frontend/package.json +42 -0
  117. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  118. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  119. lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
  120. lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
  121. lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
  122. lyrics_transcriber/frontend/public/favicon.ico +0 -0
  123. lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
  124. lyrics_transcriber/frontend/src/App.tsx +212 -0
  125. lyrics_transcriber/frontend/src/api.ts +239 -0
  126. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
  127. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  128. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
  129. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
  130. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
  131. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
  132. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
  133. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
  134. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
  135. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  136. lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
  137. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
  138. lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
  139. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  140. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  141. lyrics_transcriber/frontend/src/components/Header.tsx +387 -0
  142. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1373 -0
  143. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
  144. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
  145. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
  146. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
  147. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
  148. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
  149. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
  150. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  151. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
  152. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
  153. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
  154. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  155. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
  156. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
  157. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
  158. lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
  159. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
  160. lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
  161. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  162. lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
  163. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
  164. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  165. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
  166. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
  167. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
  168. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  169. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
  170. lyrics_transcriber/frontend/src/main.tsx +17 -0
  171. lyrics_transcriber/frontend/src/theme.ts +177 -0
  172. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  173. lyrics_transcriber/frontend/src/types.js +2 -0
  174. lyrics_transcriber/frontend/src/types.ts +199 -0
  175. lyrics_transcriber/frontend/src/validation.ts +132 -0
  176. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  177. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  178. lyrics_transcriber/frontend/tsconfig.json +25 -0
  179. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  180. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  181. lyrics_transcriber/frontend/update_version.js +11 -0
  182. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  183. lyrics_transcriber/frontend/vite.config.js +10 -0
  184. lyrics_transcriber/frontend/vite.config.ts +11 -0
  185. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  186. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  187. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  188. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js +42039 -0
  189. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  191. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  192. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  193. lyrics_transcriber/frontend/web_assets/index.html +18 -0
  194. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  195. lyrics_transcriber/frontend/yarn.lock +3752 -0
  196. lyrics_transcriber/lyrics/__init__.py +0 -0
  197. lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
  198. lyrics_transcriber/lyrics/file_provider.py +95 -0
  199. lyrics_transcriber/lyrics/genius.py +384 -0
  200. lyrics_transcriber/lyrics/lrclib.py +231 -0
  201. lyrics_transcriber/lyrics/musixmatch.py +156 -0
  202. lyrics_transcriber/lyrics/spotify.py +290 -0
  203. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  204. lyrics_transcriber/output/__init__.py +0 -0
  205. lyrics_transcriber/output/ass/__init__.py +21 -0
  206. lyrics_transcriber/output/ass/ass.py +2088 -0
  207. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  208. lyrics_transcriber/output/ass/config.py +180 -0
  209. lyrics_transcriber/output/ass/constants.py +23 -0
  210. lyrics_transcriber/output/ass/event.py +94 -0
  211. lyrics_transcriber/output/ass/formatters.py +132 -0
  212. lyrics_transcriber/output/ass/lyrics_line.py +265 -0
  213. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  214. lyrics_transcriber/output/ass/section_detector.py +89 -0
  215. lyrics_transcriber/output/ass/section_screen.py +106 -0
  216. lyrics_transcriber/output/ass/style.py +187 -0
  217. lyrics_transcriber/output/cdg.py +619 -0
  218. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  219. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  220. lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
  221. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  222. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  223. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  224. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  225. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  226. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  227. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  228. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  229. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  230. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  231. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  232. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  233. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  234. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  235. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  236. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  237. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  238. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  239. lyrics_transcriber/output/countdown_processor.py +267 -0
  240. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  241. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  242. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  243. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  244. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  245. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  246. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  247. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  248. lyrics_transcriber/output/generator.py +257 -0
  249. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  250. lyrics_transcriber/output/lyrics_file.py +102 -0
  251. lyrics_transcriber/output/plain_text.py +96 -0
  252. lyrics_transcriber/output/segment_resizer.py +431 -0
  253. lyrics_transcriber/output/subtitles.py +397 -0
  254. lyrics_transcriber/output/video.py +544 -0
  255. lyrics_transcriber/review/__init__.py +0 -0
  256. lyrics_transcriber/review/server.py +676 -0
  257. lyrics_transcriber/storage/__init__.py +0 -0
  258. lyrics_transcriber/storage/dropbox.py +225 -0
  259. lyrics_transcriber/transcribers/__init__.py +0 -0
  260. lyrics_transcriber/transcribers/audioshake.py +290 -0
  261. lyrics_transcriber/transcribers/base_transcriber.py +157 -0
  262. lyrics_transcriber/transcribers/whisper.py +330 -0
  263. lyrics_transcriber/types.py +648 -0
  264. lyrics_transcriber/utils/__init__.py +0 -0
  265. lyrics_transcriber/utils/word_utils.py +27 -0
  266. karaoke_gen-0.57.0.dist-info/METADATA +0 -167
  267. karaoke_gen-0.57.0.dist-info/RECORD +0 -23
  268. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,676 @@
1
+ import logging
2
+ import socket
3
+ from fastapi import FastAPI, Body, HTTPException
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from typing import Dict, Any, List, Optional
6
+ from lyrics_transcriber.types import CorrectionResult, WordCorrection, LyricsSegment, LyricsData, LyricsMetadata, Word
7
+ import time
8
+ import os
9
+ import urllib.parse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from fastapi.responses import FileResponse, JSONResponse
12
+ import hashlib
13
+ from lyrics_transcriber.core.config import OutputConfig
14
+ import uvicorn
15
+ import webbrowser
16
+ from threading import Thread
17
+ from lyrics_transcriber.output.generator import OutputGenerator
18
+ import json
19
+ from lyrics_transcriber.correction.corrector import LyricsCorrector
20
+ from lyrics_transcriber.types import TranscriptionResult, TranscriptionData
21
+ from lyrics_transcriber.lyrics.user_input_provider import UserInputProvider
22
+ from lyrics_transcriber.correction.operations import CorrectionOperations
23
+ import uuid
24
+
25
+ try:
26
+ # Optional: used to introspect local models for /api/v1/models
27
+ from lyrics_transcriber.correction.agentic.providers.health import (
28
+ is_ollama_available,
29
+ get_ollama_models,
30
+ )
31
+ except Exception:
32
+ def is_ollama_available() -> bool: # type: ignore
33
+ return False
34
+
35
+ def get_ollama_models(): # type: ignore
36
+ return []
37
+
38
+ try:
39
+ from lyrics_transcriber.correction.agentic.observability.metrics import MetricsAggregator
40
+ except Exception:
41
+ MetricsAggregator = None # type: ignore
42
+
43
+ try:
44
+ from lyrics_transcriber.correction.agentic.observability.langfuse_integration import (
45
+ setup_langfuse,
46
+ record_metrics as lf_record,
47
+ )
48
+ except Exception:
49
+ setup_langfuse = lambda *args, **kwargs: None # type: ignore
50
+ lf_record = lambda *args, **kwargs: None # type: ignore
51
+
52
+ try:
53
+ from lyrics_transcriber.correction.agentic.feedback.store import FeedbackStore
54
+ except Exception:
55
+ FeedbackStore = None # type: ignore
56
+
57
+ try:
58
+ from lyrics_transcriber.correction.feedback.store import FeedbackStore as NewFeedbackStore
59
+ from lyrics_transcriber.correction.feedback.schemas import CorrectionAnnotation
60
+ except Exception:
61
+ NewFeedbackStore = None # type: ignore
62
+ CorrectionAnnotation = None # type: ignore
63
+
64
+
65
+ class ReviewServer:
66
+ """Handles the review process through a web interface."""
67
+
68
+ def __init__(
69
+ self,
70
+ correction_result: CorrectionResult,
71
+ output_config: OutputConfig,
72
+ audio_filepath: str,
73
+ logger: logging.Logger,
74
+ ):
75
+ """Initialize the review server."""
76
+ self.correction_result = correction_result
77
+ self.output_config = output_config
78
+ self.audio_filepath = audio_filepath
79
+ self.logger = logger or logging.getLogger(__name__)
80
+ self.review_completed = False
81
+
82
+ # Create FastAPI instance and configure
83
+ self.app = FastAPI()
84
+ self._configure_cors()
85
+ self._register_routes()
86
+ self._mount_frontend()
87
+ # Initialize optional SQLite store for sessions/feedback (legacy)
88
+ try:
89
+ default_db = os.path.join(self.output_config.cache_dir, "agentic_feedback.sqlite3")
90
+ self._store = FeedbackStore(default_db) if FeedbackStore else None
91
+ except Exception:
92
+ self._store = None
93
+
94
+ # Initialize new annotation store
95
+ try:
96
+ self._annotation_store = NewFeedbackStore(storage_dir=self.output_config.cache_dir) if NewFeedbackStore else None
97
+ except Exception:
98
+ self._annotation_store = None
99
+ # Metrics aggregator
100
+ self._metrics = MetricsAggregator() if MetricsAggregator else None
101
+ # LangFuse (optional)
102
+ try:
103
+ self._langfuse = setup_langfuse("agentic-corrector")
104
+ except Exception:
105
+ self._langfuse = None
106
+
107
+ def _configure_cors(self) -> None:
108
+ """Configure CORS middleware."""
109
+ # Allow localhost development ports and the hosted review UI
110
+ allowed_origins = (
111
+ [f"http://localhost:{port}" for port in range(3000, 5174)]
112
+ + [f"http://127.0.0.1:{port}" for port in range(3000, 5174)]
113
+ + ["https://lyrics.nomadkaraoke.com"]
114
+ )
115
+
116
+ # Also allow custom review UI URL if set
117
+ custom_ui = os.environ.get("LYRICS_REVIEW_UI_URL", "")
118
+ if custom_ui and custom_ui.lower() != "local" and custom_ui not in allowed_origins:
119
+ allowed_origins.append(custom_ui)
120
+
121
+ self.app.add_middleware(
122
+ CORSMiddleware,
123
+ allow_origins=allowed_origins,
124
+ allow_credentials=True,
125
+ allow_methods=["*"],
126
+ allow_headers=["*"],
127
+ )
128
+
129
+ @self.app.exception_handler(HTTPException)
130
+ async def _http_exception_handler(request, exc: HTTPException):
131
+ return JSONResponse(status_code=exc.status_code, content={"error": "HTTPException", "message": exc.detail, "details": {}})
132
+
133
+ @self.app.exception_handler(Exception)
134
+ async def _unhandled_exception_handler(request, exc: Exception):
135
+ return JSONResponse(status_code=500, content={"error": "InternalServerError", "message": str(exc), "details": {}})
136
+
137
+ def _mount_frontend(self) -> None:
138
+ """Mount the frontend static files."""
139
+ current_dir = os.path.dirname(os.path.abspath(__file__))
140
+ from lyrics_transcriber.frontend import get_frontend_assets_dir
141
+ frontend_dir = get_frontend_assets_dir()
142
+
143
+ if not os.path.exists(frontend_dir):
144
+ raise FileNotFoundError(f"Frontend assets not found at {frontend_dir}")
145
+
146
+ self.app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
147
+
148
+ def _register_routes(self) -> None:
149
+ """Register API routes."""
150
+ self.app.add_api_route("/api/correction-data", self.get_correction_data, methods=["GET"])
151
+ self.app.add_api_route("/api/complete", self.complete_review, methods=["POST"])
152
+ self.app.add_api_route("/api/preview-video", self.generate_preview_video, methods=["POST"])
153
+ self.app.add_api_route("/api/preview-video/{preview_hash}", self.get_preview_video, methods=["GET"])
154
+ self.app.add_api_route("/api/audio/{audio_hash}", self.get_audio, methods=["GET"])
155
+ self.app.add_api_route("/api/ping", self.ping, methods=["GET"])
156
+ self.app.add_api_route("/api/handlers", self.update_handlers, methods=["POST"])
157
+ self.app.add_api_route("/api/add-lyrics", self.add_lyrics, methods=["POST"])
158
+
159
+ # Agentic AI v1 endpoints (contract-compliant scaffolds)
160
+ self.app.add_api_route("/api/v1/correction/agentic", self.post_correction_agentic, methods=["POST"])
161
+ self.app.add_api_route("/api/v1/correction/session/{session_id}", self.get_correction_session_v1, methods=["GET"])
162
+ self.app.add_api_route("/api/v1/feedback", self.post_feedback_v1, methods=["POST"])
163
+ self.app.add_api_route("/api/v1/models", self.get_models_v1, methods=["GET"])
164
+ self.app.add_api_route("/api/v1/models", self.put_models_v1, methods=["PUT"])
165
+ self.app.add_api_route("/api/v1/metrics", self.get_metrics_v1, methods=["GET"])
166
+
167
+ # Annotation endpoints
168
+ self.app.add_api_route("/api/v1/annotations", self.post_annotation, methods=["POST"])
169
+ self.app.add_api_route("/api/v1/annotations/{audio_hash}", self.get_annotations_by_song, methods=["GET"])
170
+ self.app.add_api_route("/api/v1/annotations/stats", self.get_annotation_stats, methods=["GET"])
171
+
172
+ async def get_correction_data(self):
173
+ """Get the correction data."""
174
+ return self.correction_result.to_dict()
175
+
176
+ # ------------------------------
177
+ # API v1: Agentic AI scaffolds
178
+ # ------------------------------
179
+
180
+ @property
181
+ def _session_store(self) -> Dict[str, Dict[str, Any]]:
182
+ if not hasattr(self, "__session_store"):
183
+ self.__session_store = {}
184
+ return self.__session_store # type: ignore[attr-defined]
185
+
186
+ @property
187
+ def _feedback_store(self) -> Dict[str, Dict[str, Any]]:
188
+ if not hasattr(self, "__feedback_store"):
189
+ self.__feedback_store = {}
190
+ return self.__feedback_store # type: ignore[attr-defined]
191
+
192
+ @property
193
+ def _model_registry(self) -> Dict[str, Dict[str, Any]]:
194
+ if not hasattr(self, "__model_registry"):
195
+ # Seed with a few placeholders
196
+ models: Dict[str, Dict[str, Any]] = {}
197
+ # Local models via Ollama
198
+ if is_ollama_available():
199
+ for m in get_ollama_models():
200
+ mid = m.get("model") or m.get("name") or "ollama-unknown"
201
+ models[mid] = {
202
+ "id": mid,
203
+ "name": mid,
204
+ "type": "local",
205
+ "available": True,
206
+ "responseTimeMs": 0,
207
+ "costPerToken": 0.0,
208
+ "accuracy": 0.0,
209
+ }
210
+ # Cloud placeholders
211
+ for mid in ["anthropic/claude-4-sonnet", "gpt-5", "gemini-2.5-pro"]:
212
+ if mid not in models:
213
+ models[mid] = {
214
+ "id": mid,
215
+ "name": mid,
216
+ "type": "cloud",
217
+ "available": False,
218
+ "responseTimeMs": 0,
219
+ "costPerToken": 0.0,
220
+ "accuracy": 0.0,
221
+ }
222
+ self.__model_registry = models
223
+ return self.__model_registry # type: ignore[attr-defined]
224
+
225
+ async def post_correction_agentic(self, request: Dict[str, Any] = Body(...)):
226
+ """POST /api/v1/correction/agentic
227
+ Minimal scaffold: validates required fields and returns a stub response.
228
+ """
229
+ start_time = time.time()
230
+ if not isinstance(request, dict):
231
+ raise HTTPException(status_code=400, detail="Invalid request body")
232
+
233
+ if "transcriptionData" not in request or "audioFileHash" not in request:
234
+ raise HTTPException(status_code=400, detail="Missing required fields: transcriptionData, audioFileHash")
235
+
236
+ session_id = str(uuid.uuid4())
237
+ session_record = {
238
+ "id": session_id,
239
+ "audioFileHash": request.get("audioFileHash"),
240
+ "sessionType": "FULL_CORRECTION",
241
+ "aiModelConfig": {"model": (request.get("modelPreferences") or [None])[0]},
242
+ "totalCorrections": 0,
243
+ "acceptedCorrections": 0,
244
+ "humanModifications": 0,
245
+ "sessionDurationMs": 0,
246
+ "accuracyImprovement": 0.0,
247
+ "startedAt": None,
248
+ "completedAt": None,
249
+ "status": "IN_PROGRESS",
250
+ }
251
+ self._session_store[session_id] = session_record
252
+ if self._store:
253
+ try:
254
+ self._store.put_session(session_id, json.dumps(session_record))
255
+ except Exception:
256
+ pass
257
+
258
+ # Simulate provider availability based on model preferences
259
+ preferred = (request.get("modelPreferences") or ["unknown"])[0]
260
+ model_entry = self._model_registry.get(preferred)
261
+ if model_entry and not model_entry.get("available", False):
262
+ # Service unavailable → return 503 with fallback details
263
+ from fastapi.responses import JSONResponse
264
+ if self._metrics:
265
+ self._metrics.record_session(preferred, int((time.time() - start_time) * 1000), fallback_used=True)
266
+ content = {
267
+ "corrections": [],
268
+ "fallbackReason": f"Model {preferred} unavailable",
269
+ "originalSystemUsed": "rule-based",
270
+ "processingTimeMs": int((time.time() - start_time) * 1000),
271
+ }
272
+ lf_record(self._langfuse, "post_correction_agentic_fallback", {"model": preferred, **content})
273
+ return JSONResponse(status_code=503, content=content)
274
+
275
+ response = {
276
+ "sessionId": session_id,
277
+ "corrections": [],
278
+ "processingTimeMs": int((time.time() - start_time) * 1000),
279
+ "modelUsed": preferred,
280
+ "fallbackUsed": False,
281
+ "accuracyEstimate": 0.0,
282
+ }
283
+ if self._metrics:
284
+ self._metrics.record_session(preferred, response["processingTimeMs"], fallback_used=False)
285
+ lf_record(self._langfuse, "post_correction_agentic", {"model": preferred, **response})
286
+ return response
287
+
288
+ async def get_correction_session_v1(self, session_id: str):
289
+ data = self._session_store.get(session_id)
290
+ if not data:
291
+ raise HTTPException(status_code=404, detail="Session not found")
292
+ return data
293
+
294
+ async def post_feedback_v1(self, request: Dict[str, Any] = Body(...)):
295
+ if not isinstance(request, dict):
296
+ raise HTTPException(status_code=400, detail="Invalid request body")
297
+ required = ["aiCorrectionId", "reviewerAction", "reasonCategory"]
298
+ if any(k not in request for k in required):
299
+ raise HTTPException(status_code=400, detail="Missing required feedback fields")
300
+
301
+ feedback_id = str(uuid.uuid4())
302
+ record = {**request, "id": feedback_id}
303
+ self._feedback_store[feedback_id] = record
304
+ if self._store:
305
+ try:
306
+ self._store.put_feedback(feedback_id, request.get("sessionId"), json.dumps(record))
307
+ except Exception:
308
+ pass
309
+ if self._metrics:
310
+ self._metrics.record_feedback()
311
+ return {"feedbackId": feedback_id, "recorded": True, "learningDataUpdated": False}
312
+
313
+ async def get_models_v1(self):
314
+ return {"models": list(self._model_registry.values())}
315
+
316
+ async def put_models_v1(self, config: Dict[str, Any] = Body(...)):
317
+ if not isinstance(config, dict) or "modelId" not in config:
318
+ raise HTTPException(status_code=400, detail="Invalid model configuration")
319
+ mid = config["modelId"]
320
+ entry = self._model_registry.get(mid, {
321
+ "id": mid,
322
+ "name": mid,
323
+ "type": "cloud",
324
+ "available": False,
325
+ "responseTimeMs": 0,
326
+ "costPerToken": 0.0,
327
+ "accuracy": 0.0,
328
+ })
329
+ if "enabled" in config:
330
+ entry["available"] = bool(config["enabled"]) or entry.get("available", False)
331
+ if "priority" in config:
332
+ entry["priority"] = config["priority"]
333
+ if "configuration" in config and isinstance(config["configuration"], dict):
334
+ entry["configuration"] = config["configuration"]
335
+ self._model_registry[mid] = entry
336
+ return {"status": "ok"}
337
+
338
+ async def get_metrics_v1(self, timeRange: str = "day", sessionId: Optional[str] = None):
339
+ if self._metrics:
340
+ return self._metrics.snapshot(time_range=timeRange, session_id=sessionId)
341
+ # Fallback if metrics unavailable
342
+ return {"timeRange": timeRange, "totalSessions": len(self._session_store), "averageAccuracy": 0.0, "errorReduction": 0.0, "averageProcessingTime": 0, "modelPerformance": {}, "costSummary": {}, "userSatisfaction": 0.0}
343
+
344
+ # ------------------------------
345
+ # Annotation endpoints
346
+ # ------------------------------
347
+
348
+ async def post_annotation(self, annotation_data: Dict[str, Any] = Body(...)):
349
+ """Save a correction annotation."""
350
+ if not self._annotation_store or not CorrectionAnnotation:
351
+ raise HTTPException(status_code=501, detail="Annotation system not available")
352
+
353
+ try:
354
+ # Validate and create annotation
355
+ annotation = CorrectionAnnotation.model_validate(annotation_data)
356
+
357
+ # Save to store
358
+ success = self._annotation_store.save_annotation(annotation)
359
+
360
+ if success:
361
+ return {"status": "success", "annotation_id": annotation.annotation_id}
362
+ else:
363
+ raise HTTPException(status_code=500, detail="Failed to save annotation")
364
+
365
+ except Exception as e:
366
+ self.logger.error(f"Failed to save annotation: {e}")
367
+ raise HTTPException(status_code=400, detail=str(e))
368
+
369
+ async def get_annotations_by_song(self, audio_hash: str):
370
+ """Get all annotations for a specific song."""
371
+ if not self._annotation_store:
372
+ raise HTTPException(status_code=501, detail="Annotation system not available")
373
+
374
+ try:
375
+ annotations = self._annotation_store.get_annotations_by_song(audio_hash)
376
+ return {
377
+ "audio_hash": audio_hash,
378
+ "count": len(annotations),
379
+ "annotations": [a.model_dump() for a in annotations]
380
+ }
381
+ except Exception as e:
382
+ self.logger.error(f"Failed to get annotations: {e}")
383
+ raise HTTPException(status_code=500, detail=str(e))
384
+
385
+ async def get_annotation_stats(self):
386
+ """Get aggregated statistics from all annotations."""
387
+ if not self._annotation_store:
388
+ raise HTTPException(status_code=501, detail="Annotation system not available")
389
+
390
+ try:
391
+ stats = self._annotation_store.get_statistics()
392
+ return stats.model_dump()
393
+ except Exception as e:
394
+ self.logger.error(f"Failed to get annotation statistics: {e}")
395
+ raise HTTPException(status_code=500, detail=str(e))
396
+
397
+ def _update_correction_result(self, base_result: CorrectionResult, updated_data: Dict[str, Any]) -> CorrectionResult:
398
+ """Update a CorrectionResult with new correction data."""
399
+ return CorrectionOperations.update_correction_result_with_data(base_result, updated_data)
400
+
401
+ async def complete_review(self, updated_data: Dict[str, Any] = Body(...)):
402
+ """Complete the review process."""
403
+ try:
404
+ self.correction_result = self._update_correction_result(self.correction_result, updated_data)
405
+ self.review_completed = True
406
+ return {"status": "success"}
407
+ except Exception as e:
408
+ self.logger.error(f"Failed to update correction data: {str(e)}")
409
+ return {"status": "error", "message": str(e)}
410
+
411
+ async def ping(self):
412
+ """Simple ping endpoint for testing."""
413
+ return {"status": "ok"}
414
+
415
+ async def get_audio(self, audio_hash: str):
416
+ """Stream the audio file."""
417
+ try:
418
+ if (
419
+ not self.audio_filepath
420
+ or not os.path.exists(self.audio_filepath)
421
+ or not self.correction_result.metadata
422
+ or self.correction_result.metadata.get("audio_hash") != audio_hash
423
+ ):
424
+ raise FileNotFoundError("Audio file not found")
425
+
426
+ return FileResponse(self.audio_filepath, media_type="audio/mpeg", filename=os.path.basename(self.audio_filepath))
427
+ except Exception as e:
428
+ raise HTTPException(status_code=404, detail="Audio file not found")
429
+
430
+ async def generate_preview_video(self, updated_data: Dict[str, Any] = Body(...)):
431
+ """Generate a preview video with the current corrections."""
432
+ try:
433
+ # Use shared operation for preview generation
434
+ result = CorrectionOperations.generate_preview_video(
435
+ correction_result=self.correction_result,
436
+ updated_data=updated_data,
437
+ output_config=self.output_config,
438
+ audio_filepath=self.audio_filepath,
439
+ logger=self.logger
440
+ )
441
+
442
+ # Store the path for later retrieval
443
+ if not hasattr(self, "preview_videos"):
444
+ self.preview_videos = {}
445
+ self.preview_videos[result["preview_hash"]] = result["video_path"]
446
+
447
+ return {"status": "success", "preview_hash": result["preview_hash"]}
448
+
449
+ except Exception as e:
450
+ self.logger.error(f"Failed to generate preview video: {str(e)}")
451
+ raise HTTPException(status_code=500, detail=str(e))
452
+
453
+ async def get_preview_video(self, preview_hash: str):
454
+ """Stream the preview video."""
455
+ try:
456
+ if not hasattr(self, "preview_videos") or preview_hash not in self.preview_videos:
457
+ raise FileNotFoundError("Preview video not found")
458
+
459
+ video_path = self.preview_videos[preview_hash]
460
+ if not os.path.exists(video_path):
461
+ raise FileNotFoundError("Preview video file not found")
462
+
463
+ return FileResponse(
464
+ video_path,
465
+ media_type="video/mp4",
466
+ filename=os.path.basename(video_path),
467
+ headers={
468
+ "Accept-Ranges": "bytes",
469
+ "Content-Disposition": "inline",
470
+ "Cache-Control": "no-cache",
471
+ "X-Content-Type-Options": "nosniff",
472
+ },
473
+ )
474
+ except Exception as e:
475
+ self.logger.error(f"Failed to stream preview video: {str(e)}")
476
+ raise HTTPException(status_code=404, detail="Preview video not found")
477
+
478
+ async def update_handlers(self, enabled_handlers: List[str] = Body(...)):
479
+ """Update enabled correction handlers and rerun correction."""
480
+ try:
481
+ # Use shared operation for handler updates
482
+ self.correction_result = CorrectionOperations.update_correction_handlers(
483
+ correction_result=self.correction_result,
484
+ enabled_handlers=enabled_handlers,
485
+ cache_dir=self.output_config.cache_dir,
486
+ logger=self.logger
487
+ )
488
+
489
+ return {"status": "success", "data": self.correction_result.to_dict()}
490
+ except Exception as e:
491
+ self.logger.error(f"Failed to update handlers: {str(e)}")
492
+ raise HTTPException(status_code=500, detail=str(e))
493
+
494
+ def _create_lyrics_data_from_text(self, text: str, source: str) -> LyricsData:
495
+ """Create LyricsData object from plain text lyrics."""
496
+ self.logger.info(f"Creating LyricsData for source '{source}'")
497
+
498
+ # Split text into lines and create segments
499
+ lines = [line.strip() for line in text.split("\n") if line.strip()]
500
+ self.logger.info(f"Found {len(lines)} non-empty lines in input text")
501
+
502
+ segments = []
503
+ for i, line in enumerate(lines):
504
+ # Split line into words
505
+ word_texts = line.strip().split()
506
+ words = []
507
+
508
+ for j, word_text in enumerate(word_texts):
509
+ word = Word(
510
+ id=f"manual_{source}_word_{i}_{j}", # Create unique ID for each word
511
+ text=word_text,
512
+ start_time=0.0, # Placeholder timing
513
+ end_time=0.0,
514
+ confidence=1.0, # Reference lyrics are considered ground truth
515
+ created_during_correction=False,
516
+ )
517
+ words.append(word)
518
+
519
+ segments.append(
520
+ LyricsSegment(
521
+ id=f"manual_{source}_{i}",
522
+ text=line,
523
+ words=words, # Now including the word objects
524
+ start_time=0.0, # Placeholder timing
525
+ end_time=0.0,
526
+ )
527
+ )
528
+
529
+ # Create metadata
530
+ self.logger.info("Creating metadata for LyricsData")
531
+ metadata = LyricsMetadata(
532
+ source=source,
533
+ track_name=self.correction_result.metadata.get("title", "") or "",
534
+ artist_names=self.correction_result.metadata.get("artist", "") or "",
535
+ is_synced=False,
536
+ lyrics_provider="manual",
537
+ lyrics_provider_id="",
538
+ album_name=None,
539
+ duration_ms=None,
540
+ explicit=None,
541
+ language=None,
542
+ provider_metadata={},
543
+ )
544
+ self.logger.info(f"Created metadata: {metadata}")
545
+
546
+ lyrics_data = LyricsData(segments=segments, metadata=metadata, source=source)
547
+ self.logger.info(f"Created LyricsData with {len(segments)} segments and {sum(len(s.words) for s in segments)} total words")
548
+
549
+ return lyrics_data
550
+
551
+ async def add_lyrics(self, data: Dict[str, str] = Body(...)):
552
+ """Add new lyrics source and rerun correction."""
553
+ try:
554
+ source = data.get("source", "").strip()
555
+ lyrics_text = data.get("lyrics", "").strip()
556
+
557
+ self.logger.info(f"Received request to add lyrics source '{source}' with {len(lyrics_text)} characters")
558
+
559
+ # Use shared operation for adding lyrics source
560
+ self.correction_result = CorrectionOperations.add_lyrics_source(
561
+ correction_result=self.correction_result,
562
+ source=source,
563
+ lyrics_text=lyrics_text,
564
+ cache_dir=self.output_config.cache_dir,
565
+ logger=self.logger
566
+ )
567
+
568
+ return {"status": "success", "data": self.correction_result.to_dict()}
569
+
570
+ except ValueError as e:
571
+ # Convert ValueError to HTTPException for API consistency
572
+ raise HTTPException(status_code=400, detail=str(e))
573
+ except Exception as e:
574
+ self.logger.error(f"Failed to add lyrics: {str(e)}", exc_info=True)
575
+ raise HTTPException(status_code=500, detail=str(e))
576
+
577
+ def start(self) -> CorrectionResult:
578
+ """Start the review server and wait for completion."""
579
+ # Generate audio hash if audio file exists
580
+ if self.audio_filepath and os.path.exists(self.audio_filepath):
581
+ with open(self.audio_filepath, "rb") as f:
582
+ audio_hash = hashlib.md5(f.read()).hexdigest()
583
+ if not self.correction_result.metadata:
584
+ self.correction_result.metadata = {}
585
+ self.correction_result.metadata["audio_hash"] = audio_hash
586
+
587
+ server = None
588
+ server_thread = None
589
+ sock = None
590
+
591
+ try:
592
+ # Check port availability
593
+ while True:
594
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
595
+ sock.settimeout(1)
596
+ if sock.connect_ex(("127.0.0.1", 8000)) == 0:
597
+ # Port is in use, get process info
598
+ process_info = ""
599
+ if os.name != "nt": # Unix-like systems
600
+ try:
601
+ process_info = os.popen("lsof -i:8000").read().strip()
602
+ except:
603
+ pass
604
+
605
+ self.logger.warning(
606
+ f"Port 8000 is in use. Waiting for it to become available...\n"
607
+ f"Process using port 8000:\n{process_info}\n"
608
+ f"To manually free the port, you can run: lsof -ti:8000 | xargs kill -9"
609
+ )
610
+ sock.close()
611
+ time.sleep(30)
612
+ else:
613
+ sock.close()
614
+ break
615
+
616
+ # Start server
617
+ config = uvicorn.Config(self.app, host="127.0.0.1", port=8000, log_level="error")
618
+ server = uvicorn.Server(config)
619
+ server_thread = Thread(target=server.run, daemon=True)
620
+ server_thread.start()
621
+ time.sleep(0.5) # Reduced wait time
622
+
623
+ # Open browser and wait for completion
624
+ base_api_url = "http://localhost:8000/api"
625
+ encoded_api_url = urllib.parse.quote(base_api_url, safe="")
626
+ audio_hash_param = (
627
+ f"&audioHash={self.correction_result.metadata.get('audio_hash', '')}"
628
+ if self.correction_result.metadata and "audio_hash" in self.correction_result.metadata
629
+ else ""
630
+ )
631
+
632
+ # Use hosted review UI by default, can be overridden with LYRICS_REVIEW_UI_URL env var
633
+ # Set to "local" to use the bundled local frontend instead
634
+ review_ui_url = os.environ.get("LYRICS_REVIEW_UI_URL", "https://lyrics.nomadkaraoke.com")
635
+ if review_ui_url.lower() == "local":
636
+ # Use the bundled local frontend
637
+ browser_url = f"http://localhost:8000?baseApiUrl={encoded_api_url}{audio_hash_param}"
638
+ else:
639
+ # Use the hosted/external review UI
640
+ browser_url = f"{review_ui_url}?baseApiUrl={encoded_api_url}{audio_hash_param}"
641
+
642
+ self.logger.info(f"Opening review UI: {browser_url}")
643
+ webbrowser.open(browser_url)
644
+
645
+ while not self.review_completed:
646
+ time.sleep(0.1)
647
+
648
+ return self.correction_result
649
+
650
+ except KeyboardInterrupt:
651
+ self.logger.info("Received interrupt, shutting down server...")
652
+ raise
653
+ except Exception as e:
654
+ self.logger.error(f"Error during review server operation: {e}")
655
+ raise
656
+ finally:
657
+ # Comprehensive cleanup
658
+ if sock:
659
+ try:
660
+ sock.close()
661
+ except:
662
+ pass
663
+
664
+ if server:
665
+ server.should_exit = True
666
+
667
+ if server_thread and server_thread.is_alive():
668
+ server_thread.join(timeout=1)
669
+
670
+ # Force cleanup any remaining server resources
671
+ try:
672
+ import multiprocessing.resource_tracker
673
+
674
+ multiprocessing.resource_tracker._resource_tracker = None
675
+ except:
676
+ pass
File without changes