karaoke-gen 0.75.54__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.
Potentially problematic release.
This version of karaoke-gen might be problematic. Click here for more details.
- karaoke_gen/__init__.py +38 -0
- karaoke_gen/audio_fetcher.py +1614 -0
- karaoke_gen/audio_processor.py +790 -0
- karaoke_gen/config.py +83 -0
- karaoke_gen/file_handler.py +387 -0
- karaoke_gen/instrumental_review/__init__.py +45 -0
- karaoke_gen/instrumental_review/analyzer.py +408 -0
- karaoke_gen/instrumental_review/editor.py +322 -0
- karaoke_gen/instrumental_review/models.py +171 -0
- karaoke_gen/instrumental_review/server.py +475 -0
- karaoke_gen/instrumental_review/static/index.html +1529 -0
- karaoke_gen/instrumental_review/waveform.py +409 -0
- karaoke_gen/karaoke_finalise/__init__.py +1 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
- karaoke_gen/karaoke_gen.py +1026 -0
- karaoke_gen/lyrics_processor.py +474 -0
- karaoke_gen/metadata.py +160 -0
- karaoke_gen/pipeline/__init__.py +87 -0
- karaoke_gen/pipeline/base.py +215 -0
- karaoke_gen/pipeline/context.py +230 -0
- karaoke_gen/pipeline/executors/__init__.py +21 -0
- karaoke_gen/pipeline/executors/local.py +159 -0
- karaoke_gen/pipeline/executors/remote.py +257 -0
- karaoke_gen/pipeline/stages/__init__.py +27 -0
- karaoke_gen/pipeline/stages/finalize.py +202 -0
- karaoke_gen/pipeline/stages/render.py +165 -0
- karaoke_gen/pipeline/stages/screens.py +139 -0
- karaoke_gen/pipeline/stages/separation.py +191 -0
- karaoke_gen/pipeline/stages/transcription.py +191 -0
- karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_gen/style_loader.py +531 -0
- karaoke_gen/utils/__init__.py +18 -0
- karaoke_gen/utils/bulk_cli.py +492 -0
- karaoke_gen/utils/cli_args.py +432 -0
- karaoke_gen/utils/gen_cli.py +978 -0
- karaoke_gen/utils/remote_cli.py +3268 -0
- karaoke_gen/video_background_processor.py +351 -0
- karaoke_gen/video_generator.py +424 -0
- karaoke_gen-0.75.54.dist-info/METADATA +718 -0
- karaoke_gen-0.75.54.dist-info/RECORD +287 -0
- karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
- karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
- karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
- lyrics_transcriber/__init__.py +10 -0
- lyrics_transcriber/cli/__init__.py +0 -0
- lyrics_transcriber/cli/cli_main.py +285 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/config.py +50 -0
- lyrics_transcriber/core/controller.py +594 -0
- lyrics_transcriber/correction/__init__.py +0 -0
- lyrics_transcriber/correction/agentic/__init__.py +9 -0
- lyrics_transcriber/correction/agentic/adapter.py +71 -0
- lyrics_transcriber/correction/agentic/agent.py +313 -0
- lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
- lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
- lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
- lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
- lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
- lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
- lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
- lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
- lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
- lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
- lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
- lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
- lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
- lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
- lyrics_transcriber/correction/agentic/models/enums.py +38 -0
- lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
- lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
- lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
- lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
- lyrics_transcriber/correction/agentic/models/utils.py +19 -0
- lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
- lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
- lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
- lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
- lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
- lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
- lyrics_transcriber/correction/agentic/providers/base.py +36 -0
- lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
- lyrics_transcriber/correction/agentic/providers/config.py +73 -0
- lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
- lyrics_transcriber/correction/agentic/providers/health.py +28 -0
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
- lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
- lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
- lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
- lyrics_transcriber/correction/agentic/router.py +35 -0
- lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
- lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
- lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
- lyrics_transcriber/correction/anchor_sequence.py +919 -0
- lyrics_transcriber/correction/corrector.py +760 -0
- lyrics_transcriber/correction/feedback/__init__.py +2 -0
- lyrics_transcriber/correction/feedback/schemas.py +107 -0
- lyrics_transcriber/correction/feedback/store.py +236 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +52 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
- lyrics_transcriber/correction/handlers/llm.py +293 -0
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
- lyrics_transcriber/correction/handlers/repeat.py +88 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
- lyrics_transcriber/correction/handlers/word_operations.py +187 -0
- lyrics_transcriber/correction/operations.py +352 -0
- lyrics_transcriber/correction/phrase_analyzer.py +435 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/frontend/.gitignore +23 -0
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
- lyrics_transcriber/frontend/.yarnrc.yml +3 -0
- lyrics_transcriber/frontend/README.md +50 -0
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/__init__.py +25 -0
- lyrics_transcriber/frontend/eslint.config.js +28 -0
- lyrics_transcriber/frontend/index.html +18 -0
- lyrics_transcriber/frontend/package.json +42 -0
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/public/favicon.ico +0 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +214 -0
- lyrics_transcriber/frontend/src/api.ts +254 -0
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
- lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
- lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
- lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
- lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
- lyrics_transcriber/frontend/src/main.tsx +17 -0
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
- lyrics_transcriber/frontend/src/types.js +2 -0
- lyrics_transcriber/frontend/src/types.ts +199 -0
- lyrics_transcriber/frontend/src/validation.ts +132 -0
- lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
- lyrics_transcriber/frontend/tsconfig.app.json +26 -0
- lyrics_transcriber/frontend/tsconfig.json +25 -0
- lyrics_transcriber/frontend/tsconfig.node.json +23 -0
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
- lyrics_transcriber/frontend/update_version.js +11 -0
- lyrics_transcriber/frontend/vite.config.d.ts +2 -0
- lyrics_transcriber/frontend/vite.config.js +10 -0
- lyrics_transcriber/frontend/vite.config.ts +11 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +18 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/yarn.lock +3752 -0
- lyrics_transcriber/lyrics/__init__.py +0 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
- lyrics_transcriber/lyrics/file_provider.py +95 -0
- lyrics_transcriber/lyrics/genius.py +384 -0
- lyrics_transcriber/lyrics/lrclib.py +231 -0
- lyrics_transcriber/lyrics/musixmatch.py +156 -0
- lyrics_transcriber/lyrics/spotify.py +290 -0
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/ass/ass.py +2088 -0
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +180 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +265 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +619 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/countdown_processor.py +306 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +257 -0
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +96 -0
- lyrics_transcriber/output/segment_resizer.py +431 -0
- lyrics_transcriber/output/subtitles.py +397 -0
- lyrics_transcriber/output/video.py +544 -0
- lyrics_transcriber/review/__init__.py +0 -0
- lyrics_transcriber/review/server.py +676 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +225 -0
- lyrics_transcriber/transcribers/__init__.py +0 -0
- lyrics_transcriber/transcribers/audioshake.py +379 -0
- lyrics_transcriber/transcribers/base_transcriber.py +157 -0
- lyrics_transcriber/transcribers/whisper.py +330 -0
- lyrics_transcriber/types.py +650 -0
- lyrics_transcriber/utils/__init__.py +0 -0
- lyrics_transcriber/utils/word_utils.py +27 -0
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Instrumental Review</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0f0f0f;
|
|
10
|
+
--card: #1a1a1a;
|
|
11
|
+
--card-border: #2a2a2a;
|
|
12
|
+
--waveform-bg: #0d1117;
|
|
13
|
+
--text: #e5e5e5;
|
|
14
|
+
--text-muted: #888;
|
|
15
|
+
--primary: #3b82f6;
|
|
16
|
+
--primary-hover: #2563eb;
|
|
17
|
+
--secondary: #252525;
|
|
18
|
+
--secondary-hover: #333;
|
|
19
|
+
--success: #22c55e;
|
|
20
|
+
--warning: #f59e0b;
|
|
21
|
+
--danger: #ef4444;
|
|
22
|
+
--pink: #ec4899;
|
|
23
|
+
--blue: #60a5fa;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
color: var(--text);
|
|
32
|
+
line-height: 1.5;
|
|
33
|
+
height: 100vh;
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.app {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
height: 100vh;
|
|
41
|
+
padding: 16px 24px;
|
|
42
|
+
gap: 12px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Header - compact */
|
|
46
|
+
.header {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: space-between;
|
|
50
|
+
gap: 16px;
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.header-left {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 12px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.logo {
|
|
61
|
+
font-size: 1.25rem;
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.track-info {
|
|
66
|
+
font-size: 0.9rem;
|
|
67
|
+
color: var(--text-muted);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.header-right {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 8px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.badge {
|
|
77
|
+
display: inline-flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
padding: 4px 10px;
|
|
80
|
+
border-radius: 12px;
|
|
81
|
+
font-size: 0.7rem;
|
|
82
|
+
font-weight: 500;
|
|
83
|
+
background: var(--secondary);
|
|
84
|
+
color: var(--text-muted);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
|
88
|
+
.badge-success { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
|
89
|
+
|
|
90
|
+
/* Main waveform player */
|
|
91
|
+
.waveform-player {
|
|
92
|
+
background: var(--card);
|
|
93
|
+
border: 1px solid var(--card-border);
|
|
94
|
+
border-radius: 12px;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
flex: 1;
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
min-height: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.waveform-toolbar {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: space-between;
|
|
106
|
+
padding: 10px 16px;
|
|
107
|
+
background: var(--secondary);
|
|
108
|
+
border-bottom: 1px solid var(--card-border);
|
|
109
|
+
gap: 12px;
|
|
110
|
+
flex-shrink: 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.toolbar-left {
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: 8px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.toolbar-center {
|
|
120
|
+
display: flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
gap: 6px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.toolbar-right {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: 12px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.time-display {
|
|
132
|
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
|
133
|
+
font-size: 0.85rem;
|
|
134
|
+
color: var(--text);
|
|
135
|
+
min-width: 90px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.btn {
|
|
139
|
+
display: inline-flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
justify-content: center;
|
|
142
|
+
padding: 6px 12px;
|
|
143
|
+
border-radius: 6px;
|
|
144
|
+
font-size: 0.8rem;
|
|
145
|
+
font-weight: 500;
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
border: none;
|
|
148
|
+
transition: all 0.15s;
|
|
149
|
+
gap: 6px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.btn-icon {
|
|
153
|
+
width: 32px;
|
|
154
|
+
height: 32px;
|
|
155
|
+
padding: 0;
|
|
156
|
+
border-radius: 50%;
|
|
157
|
+
font-size: 1rem;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.btn-primary { background: var(--primary); color: white; }
|
|
161
|
+
.btn-primary:hover { background: var(--primary-hover); }
|
|
162
|
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
163
|
+
|
|
164
|
+
.btn-secondary { background: var(--secondary); color: var(--text); border: 1px solid var(--card-border); }
|
|
165
|
+
.btn-secondary:hover { background: var(--secondary-hover); }
|
|
166
|
+
.btn-secondary.active { background: var(--primary); border-color: var(--primary); }
|
|
167
|
+
|
|
168
|
+
.btn-ghost { background: transparent; color: var(--text-muted); }
|
|
169
|
+
.btn-ghost:hover { background: var(--secondary); color: var(--text); }
|
|
170
|
+
.btn-ghost.active { background: var(--primary); color: white; }
|
|
171
|
+
|
|
172
|
+
.btn-sm { padding: 4px 8px; font-size: 0.75rem; }
|
|
173
|
+
|
|
174
|
+
.btn-danger { background: var(--danger); color: white; }
|
|
175
|
+
.btn-success { background: var(--success); color: white; }
|
|
176
|
+
|
|
177
|
+
.audio-toggle-group {
|
|
178
|
+
display: flex;
|
|
179
|
+
background: var(--bg);
|
|
180
|
+
border-radius: 6px;
|
|
181
|
+
padding: 2px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.audio-toggle {
|
|
185
|
+
padding: 5px 10px;
|
|
186
|
+
border-radius: 4px;
|
|
187
|
+
font-size: 0.75rem;
|
|
188
|
+
font-weight: 500;
|
|
189
|
+
color: var(--text-muted);
|
|
190
|
+
background: transparent;
|
|
191
|
+
border: none;
|
|
192
|
+
cursor: pointer;
|
|
193
|
+
transition: all 0.15s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.audio-toggle:hover { color: var(--text); }
|
|
197
|
+
.audio-toggle.active { background: var(--primary); color: white; }
|
|
198
|
+
|
|
199
|
+
/* Waveform canvas area */
|
|
200
|
+
.waveform-container {
|
|
201
|
+
flex: 1;
|
|
202
|
+
position: relative;
|
|
203
|
+
overflow-x: auto;
|
|
204
|
+
overflow-y: hidden;
|
|
205
|
+
background: var(--waveform-bg);
|
|
206
|
+
min-height: 120px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.waveform-area {
|
|
210
|
+
position: relative;
|
|
211
|
+
height: 100%;
|
|
212
|
+
min-width: 100%;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#waveform-canvas {
|
|
216
|
+
display: block;
|
|
217
|
+
height: 100%;
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* Zoom controls */
|
|
222
|
+
.zoom-controls {
|
|
223
|
+
display: flex;
|
|
224
|
+
align-items: center;
|
|
225
|
+
gap: 4px;
|
|
226
|
+
background: var(--bg);
|
|
227
|
+
border-radius: 6px;
|
|
228
|
+
padding: 2px;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.zoom-btn {
|
|
232
|
+
width: 28px;
|
|
233
|
+
height: 28px;
|
|
234
|
+
display: flex;
|
|
235
|
+
align-items: center;
|
|
236
|
+
justify-content: center;
|
|
237
|
+
background: transparent;
|
|
238
|
+
border: none;
|
|
239
|
+
color: var(--text-muted);
|
|
240
|
+
cursor: pointer;
|
|
241
|
+
border-radius: 4px;
|
|
242
|
+
font-size: 0.9rem;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.zoom-btn:hover { background: var(--secondary); color: var(--text); }
|
|
246
|
+
.zoom-btn.active { background: var(--primary); color: white; }
|
|
247
|
+
|
|
248
|
+
.zoom-label {
|
|
249
|
+
font-size: 0.7rem;
|
|
250
|
+
color: var(--text-muted);
|
|
251
|
+
min-width: 28px;
|
|
252
|
+
text-align: center;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* Upload button */
|
|
256
|
+
.upload-btn {
|
|
257
|
+
position: relative;
|
|
258
|
+
overflow: hidden;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.upload-btn input[type="file"] {
|
|
262
|
+
position: absolute;
|
|
263
|
+
top: 0;
|
|
264
|
+
left: 0;
|
|
265
|
+
width: 100%;
|
|
266
|
+
height: 100%;
|
|
267
|
+
opacity: 0;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.upload-progress {
|
|
272
|
+
position: fixed;
|
|
273
|
+
top: 50%;
|
|
274
|
+
left: 50%;
|
|
275
|
+
transform: translate(-50%, -50%);
|
|
276
|
+
background: var(--card);
|
|
277
|
+
border: 1px solid var(--card-border);
|
|
278
|
+
border-radius: 12px;
|
|
279
|
+
padding: 24px 32px;
|
|
280
|
+
z-index: 1000;
|
|
281
|
+
text-align: center;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.upload-overlay {
|
|
285
|
+
position: fixed;
|
|
286
|
+
top: 0;
|
|
287
|
+
left: 0;
|
|
288
|
+
width: 100%;
|
|
289
|
+
height: 100%;
|
|
290
|
+
background: rgba(0,0,0,0.6);
|
|
291
|
+
z-index: 999;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.playhead {
|
|
295
|
+
position: absolute;
|
|
296
|
+
top: 0;
|
|
297
|
+
width: 2px;
|
|
298
|
+
height: 100%;
|
|
299
|
+
background: var(--primary);
|
|
300
|
+
pointer-events: none;
|
|
301
|
+
z-index: 10;
|
|
302
|
+
box-shadow: 0 0 8px var(--primary);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.playhead::after {
|
|
306
|
+
content: '';
|
|
307
|
+
position: absolute;
|
|
308
|
+
top: 0;
|
|
309
|
+
left: -4px;
|
|
310
|
+
width: 10px;
|
|
311
|
+
height: 10px;
|
|
312
|
+
background: var(--primary);
|
|
313
|
+
border-radius: 50%;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.selection-overlay {
|
|
317
|
+
position: absolute;
|
|
318
|
+
top: 0;
|
|
319
|
+
height: 100%;
|
|
320
|
+
background: rgba(59, 130, 246, 0.3);
|
|
321
|
+
border: 1px dashed var(--primary);
|
|
322
|
+
pointer-events: none;
|
|
323
|
+
z-index: 5;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.time-axis {
|
|
327
|
+
display: flex;
|
|
328
|
+
justify-content: space-between;
|
|
329
|
+
padding: 4px 12px;
|
|
330
|
+
font-size: 0.7rem;
|
|
331
|
+
color: var(--text-muted);
|
|
332
|
+
background: rgba(0,0,0,0.4);
|
|
333
|
+
flex-shrink: 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* Hidden audio element */
|
|
337
|
+
#audio-player { display: none; }
|
|
338
|
+
|
|
339
|
+
/* Bottom section */
|
|
340
|
+
.bottom-section {
|
|
341
|
+
display: flex;
|
|
342
|
+
gap: 12px;
|
|
343
|
+
flex-shrink: 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* Mute regions panel */
|
|
347
|
+
.mute-panel {
|
|
348
|
+
flex: 1;
|
|
349
|
+
background: var(--card);
|
|
350
|
+
border: 1px solid var(--card-border);
|
|
351
|
+
border-radius: 10px;
|
|
352
|
+
padding: 12px;
|
|
353
|
+
display: flex;
|
|
354
|
+
flex-direction: column;
|
|
355
|
+
gap: 8px;
|
|
356
|
+
max-height: 140px;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.mute-panel-header {
|
|
360
|
+
display: flex;
|
|
361
|
+
align-items: center;
|
|
362
|
+
justify-content: space-between;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.mute-panel-title {
|
|
366
|
+
font-size: 0.85rem;
|
|
367
|
+
font-weight: 600;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.mute-regions-list {
|
|
371
|
+
display: flex;
|
|
372
|
+
flex-wrap: wrap;
|
|
373
|
+
gap: 6px;
|
|
374
|
+
overflow-y: auto;
|
|
375
|
+
flex: 1;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.mute-region-tag {
|
|
379
|
+
display: inline-flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
gap: 4px;
|
|
382
|
+
padding: 4px 8px;
|
|
383
|
+
background: var(--secondary);
|
|
384
|
+
border-radius: 4px;
|
|
385
|
+
font-size: 0.75rem;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.mute-region-tag button {
|
|
389
|
+
background: none;
|
|
390
|
+
border: none;
|
|
391
|
+
color: var(--text-muted);
|
|
392
|
+
cursor: pointer;
|
|
393
|
+
padding: 0;
|
|
394
|
+
font-size: 0.8rem;
|
|
395
|
+
line-height: 1;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.mute-region-tag button:hover { color: var(--danger); }
|
|
399
|
+
|
|
400
|
+
.quick-segments {
|
|
401
|
+
display: flex;
|
|
402
|
+
flex-wrap: wrap;
|
|
403
|
+
gap: 4px;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.quick-segment-btn {
|
|
407
|
+
padding: 3px 6px;
|
|
408
|
+
background: var(--bg);
|
|
409
|
+
border: 1px solid var(--card-border);
|
|
410
|
+
border-radius: 4px;
|
|
411
|
+
font-size: 0.7rem;
|
|
412
|
+
color: var(--text-muted);
|
|
413
|
+
cursor: pointer;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.quick-segment-btn:hover {
|
|
417
|
+
border-color: var(--pink);
|
|
418
|
+
color: var(--pink);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* Selection panel */
|
|
422
|
+
.selection-panel {
|
|
423
|
+
width: 340px;
|
|
424
|
+
background: var(--card);
|
|
425
|
+
border: 1px solid var(--card-border);
|
|
426
|
+
border-radius: 10px;
|
|
427
|
+
padding: 12px;
|
|
428
|
+
display: flex;
|
|
429
|
+
flex-direction: column;
|
|
430
|
+
gap: 8px;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.selection-panel-title {
|
|
434
|
+
font-size: 0.85rem;
|
|
435
|
+
font-weight: 600;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.selection-options {
|
|
439
|
+
display: flex;
|
|
440
|
+
flex-direction: column;
|
|
441
|
+
gap: 6px;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.selection-option {
|
|
445
|
+
display: flex;
|
|
446
|
+
align-items: center;
|
|
447
|
+
gap: 10px;
|
|
448
|
+
padding: 10px 12px;
|
|
449
|
+
background: var(--secondary);
|
|
450
|
+
border-radius: 8px;
|
|
451
|
+
cursor: pointer;
|
|
452
|
+
border: 2px solid transparent;
|
|
453
|
+
transition: border-color 0.15s;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.selection-option:hover { border-color: var(--card-border); }
|
|
457
|
+
.selection-option.selected { border-color: var(--primary); background: rgba(59, 130, 246, 0.1); }
|
|
458
|
+
|
|
459
|
+
.selection-option input { display: none; }
|
|
460
|
+
|
|
461
|
+
.selection-radio {
|
|
462
|
+
width: 16px;
|
|
463
|
+
height: 16px;
|
|
464
|
+
border: 2px solid var(--text-muted);
|
|
465
|
+
border-radius: 50%;
|
|
466
|
+
display: flex;
|
|
467
|
+
align-items: center;
|
|
468
|
+
justify-content: center;
|
|
469
|
+
flex-shrink: 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.selection-option.selected .selection-radio {
|
|
473
|
+
border-color: var(--primary);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.selection-option.selected .selection-radio::after {
|
|
477
|
+
content: '';
|
|
478
|
+
width: 8px;
|
|
479
|
+
height: 8px;
|
|
480
|
+
background: var(--primary);
|
|
481
|
+
border-radius: 50%;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.selection-label {
|
|
485
|
+
flex: 1;
|
|
486
|
+
font-size: 0.8rem;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.selection-label-title { font-weight: 500; }
|
|
490
|
+
.selection-label-desc { font-size: 0.7rem; color: var(--text-muted); }
|
|
491
|
+
|
|
492
|
+
.submit-btn {
|
|
493
|
+
margin-top: auto;
|
|
494
|
+
width: 100%;
|
|
495
|
+
padding: 10px;
|
|
496
|
+
font-size: 0.85rem;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/* Keyboard hints */
|
|
500
|
+
.kbd {
|
|
501
|
+
display: inline-block;
|
|
502
|
+
padding: 2px 6px;
|
|
503
|
+
background: var(--bg);
|
|
504
|
+
border: 1px solid var(--card-border);
|
|
505
|
+
border-radius: 4px;
|
|
506
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
507
|
+
font-size: 0.65rem;
|
|
508
|
+
color: var(--text-muted);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/* Alert */
|
|
512
|
+
.alert {
|
|
513
|
+
padding: 1rem;
|
|
514
|
+
border-radius: 8px;
|
|
515
|
+
text-align: center;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.alert-success {
|
|
519
|
+
background: rgba(34, 197, 94, 0.15);
|
|
520
|
+
border: 1px solid var(--success);
|
|
521
|
+
color: var(--success);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.alert-error {
|
|
525
|
+
position: fixed;
|
|
526
|
+
top: 16px;
|
|
527
|
+
left: 50%;
|
|
528
|
+
transform: translateX(-50%);
|
|
529
|
+
background: rgba(239, 68, 68, 0.95);
|
|
530
|
+
color: white;
|
|
531
|
+
padding: 10px 20px;
|
|
532
|
+
border-radius: 8px;
|
|
533
|
+
z-index: 1000;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* Loading */
|
|
537
|
+
.loading {
|
|
538
|
+
display: flex;
|
|
539
|
+
align-items: center;
|
|
540
|
+
justify-content: center;
|
|
541
|
+
height: 100%;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.spinner {
|
|
545
|
+
width: 32px;
|
|
546
|
+
height: 32px;
|
|
547
|
+
border: 3px solid var(--card-border);
|
|
548
|
+
border-top-color: var(--primary);
|
|
549
|
+
border-radius: 50%;
|
|
550
|
+
animation: spin 1s linear infinite;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
554
|
+
|
|
555
|
+
.hidden { display: none !important; }
|
|
556
|
+
|
|
557
|
+
/* Success screen */
|
|
558
|
+
.success-screen {
|
|
559
|
+
display: flex;
|
|
560
|
+
flex-direction: column;
|
|
561
|
+
align-items: center;
|
|
562
|
+
justify-content: center;
|
|
563
|
+
height: 100%;
|
|
564
|
+
gap: 16px;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.success-screen h2 {
|
|
568
|
+
font-size: 1.5rem;
|
|
569
|
+
color: var(--success);
|
|
570
|
+
}
|
|
571
|
+
</style>
|
|
572
|
+
</head>
|
|
573
|
+
<body>
|
|
574
|
+
<div class="app" id="app">
|
|
575
|
+
<div class="loading">
|
|
576
|
+
<div class="spinner"></div>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
<script>
|
|
581
|
+
// State
|
|
582
|
+
let analysisData = null;
|
|
583
|
+
let waveformData = null;
|
|
584
|
+
let muteRegions = [];
|
|
585
|
+
let currentTime = 0;
|
|
586
|
+
let duration = 0;
|
|
587
|
+
let isPlaying = false;
|
|
588
|
+
let isDragging = false;
|
|
589
|
+
let dragStartTime = 0;
|
|
590
|
+
let selectionStartX = 0;
|
|
591
|
+
let activeAudio = 'backing';
|
|
592
|
+
let selectedOption = 'with_backing';
|
|
593
|
+
let hasCustom = false;
|
|
594
|
+
let hasUploaded = false;
|
|
595
|
+
let uploadedFilename = '';
|
|
596
|
+
let hasOriginal = false;
|
|
597
|
+
let zoomLevel = 1;
|
|
598
|
+
let animationFrameId = null;
|
|
599
|
+
let currentAudioElement = null; // Track audio element reference for listener management
|
|
600
|
+
|
|
601
|
+
// Parse URL parameters for cloud mode
|
|
602
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
603
|
+
const encodedBaseApiUrl = urlParams.get('baseApiUrl');
|
|
604
|
+
const instrumentalToken = urlParams.get('instrumentalToken');
|
|
605
|
+
|
|
606
|
+
// Determine API base URL - cloud mode uses provided URL, local mode uses default
|
|
607
|
+
const API_BASE = encodedBaseApiUrl
|
|
608
|
+
? decodeURIComponent(encodedBaseApiUrl)
|
|
609
|
+
: '/api/jobs/local';
|
|
610
|
+
|
|
611
|
+
// Helper to add token to URL if available
|
|
612
|
+
function addTokenToUrl(url) {
|
|
613
|
+
if (!instrumentalToken) return url;
|
|
614
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
615
|
+
return `${url}${separator}instrumental_token=${encodeURIComponent(instrumentalToken)}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// HTML escape helper to prevent XSS
|
|
619
|
+
function escapeHtml(str) {
|
|
620
|
+
if (!str) return '';
|
|
621
|
+
const div = document.createElement('div');
|
|
622
|
+
div.textContent = str;
|
|
623
|
+
return div.innerHTML;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Named event handlers for audio (so they can be added once)
|
|
627
|
+
function onAudioPlay() { isPlaying = true; updatePlayButton(); startPlayheadAnimation(); }
|
|
628
|
+
function onAudioPause() { isPlaying = false; updatePlayButton(); stopPlayheadAnimation(); }
|
|
629
|
+
function onAudioEnded() { isPlaying = false; updatePlayButton(); stopPlayheadAnimation(); }
|
|
630
|
+
|
|
631
|
+
// Initialize
|
|
632
|
+
async function init() {
|
|
633
|
+
try {
|
|
634
|
+
const [analysisRes, waveformRes] = await Promise.all([
|
|
635
|
+
fetch(addTokenToUrl(`${API_BASE}/instrumental-analysis`)),
|
|
636
|
+
fetch(addTokenToUrl(`${API_BASE}/waveform-data?num_points=1000`))
|
|
637
|
+
]);
|
|
638
|
+
|
|
639
|
+
if (!analysisRes.ok) throw new Error('Failed to load analysis');
|
|
640
|
+
analysisData = await analysisRes.json();
|
|
641
|
+
|
|
642
|
+
if (waveformRes.ok) {
|
|
643
|
+
waveformData = await waveformRes.json();
|
|
644
|
+
duration = waveformData.duration;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Set initial selection based on recommendation
|
|
648
|
+
selectedOption = analysisData.analysis.recommended_selection === 'clean' ? 'clean' : 'with_backing';
|
|
649
|
+
|
|
650
|
+
// Check if there's already an uploaded instrumental
|
|
651
|
+
if (analysisData.has_uploaded_instrumental) {
|
|
652
|
+
hasUploaded = true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Check if original audio is available
|
|
656
|
+
if (analysisData.has_original) {
|
|
657
|
+
hasOriginal = true;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
render();
|
|
661
|
+
setupKeyboardShortcuts();
|
|
662
|
+
} catch (error) {
|
|
663
|
+
showError(error.message);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function render() {
|
|
668
|
+
// Pause any existing audio before rebuilding DOM to avoid AbortError
|
|
669
|
+
const existingAudio = document.getElementById('audio-player');
|
|
670
|
+
const wasPlaying = isPlaying;
|
|
671
|
+
if (existingAudio && !existingAudio.paused) {
|
|
672
|
+
existingAudio.pause();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const app = document.getElementById('app');
|
|
676
|
+
const segments = analysisData.analysis.audible_segments;
|
|
677
|
+
const hasSegments = segments.length > 0;
|
|
678
|
+
|
|
679
|
+
app.innerHTML = `
|
|
680
|
+
<div class="header">
|
|
681
|
+
<div class="header-left">
|
|
682
|
+
<span class="logo">🎤 Instrumental Review</span>
|
|
683
|
+
<span class="track-info">${escapeHtml(analysisData.artist) || ''} ${analysisData.artist && analysisData.title ? '–' : ''} ${escapeHtml(analysisData.title) || ''}</span>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="header-right">
|
|
686
|
+
${hasSegments ? `
|
|
687
|
+
<span class="badge">${segments.length} segments</span>
|
|
688
|
+
<span class="badge">${analysisData.analysis.audible_percentage.toFixed(0)}% backing vocals</span>
|
|
689
|
+
` : ''}
|
|
690
|
+
<span class="badge ${analysisData.analysis.recommended_selection === 'clean' ? 'badge-success' : 'badge-warning'}">
|
|
691
|
+
${analysisData.analysis.recommended_selection === 'clean' ? '✓ Clean recommended' : '⚠ Review needed'}
|
|
692
|
+
</span>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
|
|
696
|
+
<div id="error-container"></div>
|
|
697
|
+
|
|
698
|
+
<div class="waveform-player">
|
|
699
|
+
<div class="waveform-toolbar">
|
|
700
|
+
<div class="toolbar-left">
|
|
701
|
+
<button class="btn btn-icon btn-primary" id="play-btn" onclick="togglePlayPause()">
|
|
702
|
+
${isPlaying ? '⏸' : '▶'}
|
|
703
|
+
</button>
|
|
704
|
+
<span class="time-display">
|
|
705
|
+
<span id="current-time">${formatTime(currentTime)}</span>
|
|
706
|
+
<span style="color: var(--text-muted)"> / ${formatTime(duration)}</span>
|
|
707
|
+
</span>
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
<div class="toolbar-center">
|
|
711
|
+
<div class="audio-toggle-group">
|
|
712
|
+
${hasOriginal ? `
|
|
713
|
+
<button class="audio-toggle ${activeAudio === 'original' ? 'active' : ''}" data-audio-type="original" onclick="setActiveAudio('original')">Original</button>
|
|
714
|
+
` : ''}
|
|
715
|
+
<button class="audio-toggle ${activeAudio === 'backing' ? 'active' : ''}" data-audio-type="backing" onclick="setActiveAudio('backing')">Backing Vocals Only</button>
|
|
716
|
+
<button class="audio-toggle ${activeAudio === 'clean' ? 'active' : ''}" data-audio-type="clean" onclick="setActiveAudio('clean')">Pure Instrumental</button>
|
|
717
|
+
${analysisData.audio_urls.with_backing ? `
|
|
718
|
+
<button class="audio-toggle ${activeAudio === 'with_backing' ? 'active' : ''}" data-audio-type="with_backing" onclick="setActiveAudio('with_backing')">Instrumental + Backing</button>
|
|
719
|
+
` : ''}
|
|
720
|
+
${hasCustom ? `
|
|
721
|
+
<button class="audio-toggle ${activeAudio === 'custom' ? 'active' : ''}" data-audio-type="custom" onclick="setActiveAudio('custom')">Custom</button>
|
|
722
|
+
` : ''}
|
|
723
|
+
${hasUploaded ? `
|
|
724
|
+
<button class="audio-toggle ${activeAudio === 'uploaded' ? 'active' : ''}" data-audio-type="uploaded" onclick="setActiveAudio('uploaded')" title="${escapeHtml(uploadedFilename)}">Uploaded</button>
|
|
725
|
+
` : ''}
|
|
726
|
+
</div>
|
|
727
|
+
<label class="btn btn-sm btn-secondary upload-btn" title="Upload custom instrumental">
|
|
728
|
+
📁 Upload
|
|
729
|
+
<input type="file" accept=".flac,.mp3,.wav,.m4a,.ogg" onchange="handleUpload(event)">
|
|
730
|
+
</label>
|
|
731
|
+
</div>
|
|
732
|
+
|
|
733
|
+
<div class="toolbar-right">
|
|
734
|
+
<div class="zoom-controls">
|
|
735
|
+
<button class="zoom-btn ${zoomLevel === 1 ? 'active' : ''}" onclick="setZoom(1)" title="1x zoom">1x</button>
|
|
736
|
+
<button class="zoom-btn ${zoomLevel === 2 ? 'active' : ''}" onclick="setZoom(2)" title="2x zoom">2x</button>
|
|
737
|
+
<button class="zoom-btn ${zoomLevel === 4 ? 'active' : ''}" onclick="setZoom(4)" title="4x zoom">4x</button>
|
|
738
|
+
</div>
|
|
739
|
+
<span style="font-size: 0.7rem; color: var(--text-muted);">
|
|
740
|
+
<span class="kbd">Shift</span>+drag
|
|
741
|
+
</span>
|
|
742
|
+
<span class="kbd">Space</span>
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
|
|
746
|
+
<div class="waveform-container" id="waveform-container">
|
|
747
|
+
<div class="waveform-area" id="waveform-area" style="width: ${zoomLevel * 100}%;">
|
|
748
|
+
<canvas id="waveform-canvas"></canvas>
|
|
749
|
+
<div class="playhead hidden" id="playhead"></div>
|
|
750
|
+
<div class="selection-overlay hidden" id="selection-overlay"></div>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<div class="time-axis">
|
|
755
|
+
<span>0:00</span>
|
|
756
|
+
<span>${formatTime(duration * 0.25)}</span>
|
|
757
|
+
<span>${formatTime(duration * 0.5)}</span>
|
|
758
|
+
<span>${formatTime(duration * 0.75)}</span>
|
|
759
|
+
<span>${formatTime(duration)}</span>
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
<audio id="audio-player" src="${getAudioUrl()}"></audio>
|
|
764
|
+
|
|
765
|
+
<div class="bottom-section">
|
|
766
|
+
<div class="mute-panel">
|
|
767
|
+
<div class="mute-panel-header">
|
|
768
|
+
<span class="mute-panel-title">Mute Regions ${muteRegions.length > 0 ? `(${muteRegions.length})` : ''}</span>
|
|
769
|
+
${muteRegions.length > 0 ? `
|
|
770
|
+
<div style="display: flex; gap: 6px;">
|
|
771
|
+
<button class="btn btn-sm btn-secondary" onclick="clearAllRegions()">Clear</button>
|
|
772
|
+
${!hasCustom ? `<button class="btn btn-sm btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">Create Custom</button>` : ''}
|
|
773
|
+
</div>
|
|
774
|
+
` : ''}
|
|
775
|
+
</div>
|
|
776
|
+
|
|
777
|
+
${muteRegions.length > 0 ? `
|
|
778
|
+
<div class="mute-regions-list">
|
|
779
|
+
${muteRegions.map((r, i) => `
|
|
780
|
+
<div class="mute-region-tag">
|
|
781
|
+
<span onclick="seekTo(${r.start_seconds}, true)" style="cursor: pointer">${formatTime(r.start_seconds)} – ${formatTime(r.end_seconds)}</span>
|
|
782
|
+
<button onclick="removeRegion(${i})">×</button>
|
|
783
|
+
</div>
|
|
784
|
+
`).join('')}
|
|
785
|
+
</div>
|
|
786
|
+
` : `
|
|
787
|
+
<div style="color: var(--text-muted); font-size: 0.75rem;">
|
|
788
|
+
${hasSegments ? 'Click segments below or <kbd class="kbd">Shift</kbd> + drag on waveform' : 'No backing vocals detected – clean instrumental recommended'}
|
|
789
|
+
</div>
|
|
790
|
+
`}
|
|
791
|
+
|
|
792
|
+
${hasSegments ? `
|
|
793
|
+
<div class="quick-segments">
|
|
794
|
+
${segments.slice(0, 8).map((seg, i) => `
|
|
795
|
+
<button class="quick-segment-btn" onclick="addSegmentAsRegion(${i})" title="Add to mute regions">
|
|
796
|
+
${formatTime(seg.start_seconds)} – ${formatTime(seg.end_seconds)}
|
|
797
|
+
</button>
|
|
798
|
+
`).join('')}
|
|
799
|
+
${segments.length > 8 ? `<span style="font-size: 0.7rem; color: var(--text-muted); padding: 3px;">+${segments.length - 8} more</span>` : ''}
|
|
800
|
+
</div>
|
|
801
|
+
` : ''}
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
<div class="selection-panel">
|
|
805
|
+
<span class="selection-panel-title">Final Selection</span>
|
|
806
|
+
<div class="selection-options">
|
|
807
|
+
<label class="selection-option ${selectedOption === 'clean' ? 'selected' : ''}" onclick="setSelection('clean')">
|
|
808
|
+
<input type="radio" name="selection" value="clean">
|
|
809
|
+
<div class="selection-radio"></div>
|
|
810
|
+
<div class="selection-label">
|
|
811
|
+
<div class="selection-label-title">Clean Instrumental</div>
|
|
812
|
+
<div class="selection-label-desc">No backing vocals</div>
|
|
813
|
+
</div>
|
|
814
|
+
</label>
|
|
815
|
+
<label class="selection-option ${selectedOption === 'with_backing' ? 'selected' : ''}" onclick="setSelection('with_backing')">
|
|
816
|
+
<input type="radio" name="selection" value="with_backing">
|
|
817
|
+
<div class="selection-radio"></div>
|
|
818
|
+
<div class="selection-label">
|
|
819
|
+
<div class="selection-label-title">With Backing Vocals</div>
|
|
820
|
+
<div class="selection-label-desc">All backing vocals included</div>
|
|
821
|
+
</div>
|
|
822
|
+
</label>
|
|
823
|
+
${hasOriginal ? `
|
|
824
|
+
<label class="selection-option ${selectedOption === 'original' ? 'selected' : ''}" onclick="setSelection('original')">
|
|
825
|
+
<input type="radio" name="selection" value="original">
|
|
826
|
+
<div class="selection-radio"></div>
|
|
827
|
+
<div class="selection-label">
|
|
828
|
+
<div class="selection-label-title">Original Audio</div>
|
|
829
|
+
<div class="selection-label-desc">Full original with lead vocals</div>
|
|
830
|
+
</div>
|
|
831
|
+
</label>
|
|
832
|
+
` : ''}
|
|
833
|
+
${hasCustom ? `
|
|
834
|
+
<label class="selection-option ${selectedOption === 'custom' ? 'selected' : ''}" onclick="setSelection('custom')">
|
|
835
|
+
<input type="radio" name="selection" value="custom">
|
|
836
|
+
<div class="selection-radio"></div>
|
|
837
|
+
<div class="selection-label">
|
|
838
|
+
<div class="selection-label-title">Custom</div>
|
|
839
|
+
<div class="selection-label-desc">${muteRegions.length} regions muted</div>
|
|
840
|
+
</div>
|
|
841
|
+
</label>
|
|
842
|
+
` : ''}
|
|
843
|
+
${hasUploaded ? `
|
|
844
|
+
<label class="selection-option ${selectedOption === 'uploaded' ? 'selected' : ''}" onclick="setSelection('uploaded')">
|
|
845
|
+
<input type="radio" name="selection" value="uploaded">
|
|
846
|
+
<div class="selection-radio"></div>
|
|
847
|
+
<div class="selection-label">
|
|
848
|
+
<div class="selection-label-title">Uploaded</div>
|
|
849
|
+
<div class="selection-label-desc">${escapeHtml(uploadedFilename)}</div>
|
|
850
|
+
</div>
|
|
851
|
+
</label>
|
|
852
|
+
` : ''}
|
|
853
|
+
</div>
|
|
854
|
+
<button class="btn btn-primary submit-btn" id="submit-btn" onclick="submitSelection()">
|
|
855
|
+
✓ Confirm & Continue
|
|
856
|
+
</button>
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
`;
|
|
860
|
+
|
|
861
|
+
// Setup after render
|
|
862
|
+
if (waveformData) {
|
|
863
|
+
resizeCanvas();
|
|
864
|
+
drawWaveform();
|
|
865
|
+
setupWaveformInteraction();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Setup audio state - add listeners when element changes
|
|
869
|
+
const audio = document.getElementById('audio-player');
|
|
870
|
+
if (audio) {
|
|
871
|
+
// Check if this is a new audio element (DOM was rebuilt)
|
|
872
|
+
if (audio !== currentAudioElement) {
|
|
873
|
+
audio.addEventListener('timeupdate', onTimeUpdate);
|
|
874
|
+
audio.addEventListener('play', onAudioPlay);
|
|
875
|
+
audio.addEventListener('pause', onAudioPause);
|
|
876
|
+
audio.addEventListener('ended', onAudioEnded);
|
|
877
|
+
currentAudioElement = audio;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Restore playback position and state after audio is ready
|
|
881
|
+
audio.addEventListener('loadeddata', function onLoaded() {
|
|
882
|
+
audio.currentTime = currentTime;
|
|
883
|
+
if (wasPlaying) {
|
|
884
|
+
audio.play().catch(() => {});
|
|
885
|
+
}
|
|
886
|
+
}, { once: true });
|
|
887
|
+
|
|
888
|
+
// If already loaded (cached), set time directly
|
|
889
|
+
if (audio.readyState >= 2) {
|
|
890
|
+
audio.currentTime = currentTime;
|
|
891
|
+
if (wasPlaying) {
|
|
892
|
+
audio.play().catch(() => {});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function resizeCanvas() {
|
|
899
|
+
const canvas = document.getElementById('waveform-canvas');
|
|
900
|
+
const container = document.getElementById('waveform-container');
|
|
901
|
+
const area = document.getElementById('waveform-area');
|
|
902
|
+
if (!canvas || !container || !area) return;
|
|
903
|
+
|
|
904
|
+
// Set canvas width based on container width * zoom level
|
|
905
|
+
canvas.width = container.clientWidth * zoomLevel;
|
|
906
|
+
canvas.height = container.clientHeight;
|
|
907
|
+
|
|
908
|
+
// Update area width to match
|
|
909
|
+
area.style.width = `${zoomLevel * 100}%`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function drawWaveform() {
|
|
913
|
+
const canvas = document.getElementById('waveform-canvas');
|
|
914
|
+
if (!canvas || !waveformData) return;
|
|
915
|
+
|
|
916
|
+
const ctx = canvas.getContext('2d');
|
|
917
|
+
const { amplitudes } = waveformData;
|
|
918
|
+
const width = canvas.width;
|
|
919
|
+
const height = canvas.height;
|
|
920
|
+
const centerY = height / 2;
|
|
921
|
+
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--waveform-bg').trim();
|
|
922
|
+
|
|
923
|
+
// Clear
|
|
924
|
+
ctx.fillStyle = bgColor;
|
|
925
|
+
ctx.fillRect(0, 0, width, height);
|
|
926
|
+
|
|
927
|
+
// Draw center line
|
|
928
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
|
929
|
+
ctx.setLineDash([4, 4]);
|
|
930
|
+
ctx.beginPath();
|
|
931
|
+
ctx.moveTo(0, centerY);
|
|
932
|
+
ctx.lineTo(width, centerY);
|
|
933
|
+
ctx.stroke();
|
|
934
|
+
ctx.setLineDash([]);
|
|
935
|
+
|
|
936
|
+
// Draw waveform bars
|
|
937
|
+
const barWidth = width / amplitudes.length;
|
|
938
|
+
|
|
939
|
+
amplitudes.forEach((amp, i) => {
|
|
940
|
+
const x = i * barWidth;
|
|
941
|
+
const barHeight = Math.max(2, amp * height * 0.9);
|
|
942
|
+
const y = centerY - barHeight / 2;
|
|
943
|
+
const time = (i / amplitudes.length) * duration;
|
|
944
|
+
|
|
945
|
+
// Check regions
|
|
946
|
+
const inMuteRegion = muteRegions.some(r => time >= r.start_seconds && time <= r.end_seconds);
|
|
947
|
+
const inAudibleSegment = analysisData.analysis.audible_segments.some(
|
|
948
|
+
s => time >= s.start_seconds && time <= s.end_seconds
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
if (inMuteRegion) {
|
|
952
|
+
// Muted regions blend into background - very subtle
|
|
953
|
+
ctx.fillStyle = 'rgba(13, 17, 23, 0.8)';
|
|
954
|
+
} else if (inAudibleSegment) {
|
|
955
|
+
ctx.fillStyle = '#ec4899'; // pink for detected backing vocals
|
|
956
|
+
} else {
|
|
957
|
+
ctx.fillStyle = '#60a5fa'; // blue for rest
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
ctx.fillRect(x, y, Math.max(1, barWidth - 0.5), barHeight);
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function setupWaveformInteraction() {
|
|
965
|
+
const canvas = document.getElementById('waveform-canvas');
|
|
966
|
+
const area = document.getElementById('waveform-area');
|
|
967
|
+
if (!canvas || !area) return;
|
|
968
|
+
|
|
969
|
+
canvas.onmousedown = (e) => {
|
|
970
|
+
const rect = canvas.getBoundingClientRect();
|
|
971
|
+
const x = e.clientX - rect.left;
|
|
972
|
+
const time = (x / rect.width) * duration;
|
|
973
|
+
|
|
974
|
+
// Shift+drag to select mute region
|
|
975
|
+
if (e.shiftKey) {
|
|
976
|
+
isDragging = true;
|
|
977
|
+
dragStartTime = time;
|
|
978
|
+
selectionStartX = x;
|
|
979
|
+
updateSelectionOverlay(x, x);
|
|
980
|
+
showSelectionOverlay(true);
|
|
981
|
+
} else {
|
|
982
|
+
// Regular click to seek and play
|
|
983
|
+
seekTo(time);
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
canvas.onmousemove = (e) => {
|
|
988
|
+
if (!isDragging) return;
|
|
989
|
+
const rect = canvas.getBoundingClientRect();
|
|
990
|
+
const x = e.clientX - rect.left;
|
|
991
|
+
updateSelectionOverlay(selectionStartX, x);
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
const endDrag = (e) => {
|
|
995
|
+
if (!isDragging) return;
|
|
996
|
+
|
|
997
|
+
const rect = canvas.getBoundingClientRect();
|
|
998
|
+
const x = e.clientX - rect.left;
|
|
999
|
+
const time = (x / rect.width) * duration;
|
|
1000
|
+
|
|
1001
|
+
const start = Math.min(dragStartTime, time);
|
|
1002
|
+
const end = Math.max(dragStartTime, time);
|
|
1003
|
+
|
|
1004
|
+
if (end - start > 0.5) {
|
|
1005
|
+
addRegion(start, end);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
isDragging = false;
|
|
1009
|
+
showSelectionOverlay(false);
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
canvas.onmouseup = endDrag;
|
|
1013
|
+
canvas.onmouseleave = (e) => {
|
|
1014
|
+
if (isDragging) endDrag(e);
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function updateSelectionOverlay(startX, endX) {
|
|
1019
|
+
const overlay = document.getElementById('selection-overlay');
|
|
1020
|
+
if (!overlay) return;
|
|
1021
|
+
|
|
1022
|
+
const left = Math.min(startX, endX);
|
|
1023
|
+
const width = Math.abs(endX - startX);
|
|
1024
|
+
|
|
1025
|
+
overlay.style.left = `${left}px`;
|
|
1026
|
+
overlay.style.width = `${width}px`;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function showSelectionOverlay(show) {
|
|
1030
|
+
const overlay = document.getElementById('selection-overlay');
|
|
1031
|
+
if (overlay) {
|
|
1032
|
+
overlay.classList.toggle('hidden', !show);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function startPlayheadAnimation() {
|
|
1037
|
+
const animate = () => {
|
|
1038
|
+
updatePlayhead();
|
|
1039
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
1040
|
+
};
|
|
1041
|
+
animate();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function stopPlayheadAnimation() {
|
|
1045
|
+
if (animationFrameId) {
|
|
1046
|
+
cancelAnimationFrame(animationFrameId);
|
|
1047
|
+
animationFrameId = null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function updatePlayhead() {
|
|
1052
|
+
const playhead = document.getElementById('playhead');
|
|
1053
|
+
const canvas = document.getElementById('waveform-canvas');
|
|
1054
|
+
const audio = document.getElementById('audio-player');
|
|
1055
|
+
|
|
1056
|
+
if (!playhead || !canvas || !audio) return;
|
|
1057
|
+
|
|
1058
|
+
currentTime = audio.currentTime;
|
|
1059
|
+
|
|
1060
|
+
// Update time display regardless of playhead position validity
|
|
1061
|
+
const timeEl = document.getElementById('current-time');
|
|
1062
|
+
if (timeEl) timeEl.textContent = formatTime(currentTime);
|
|
1063
|
+
|
|
1064
|
+
// Guard against NaN/Infinity when calculating playhead position
|
|
1065
|
+
if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(canvas.width) || canvas.width <= 0) {
|
|
1066
|
+
playhead.style.left = '0px';
|
|
1067
|
+
} else {
|
|
1068
|
+
const x = Math.max(0, Math.min((currentTime / duration) * canvas.width, canvas.width));
|
|
1069
|
+
playhead.style.left = `${x}px`;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
playhead.classList.remove('hidden');
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function updatePlayButton() {
|
|
1076
|
+
const btn = document.getElementById('play-btn');
|
|
1077
|
+
if (btn) btn.innerHTML = isPlaying ? '⏸' : '▶';
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function togglePlayPause() {
|
|
1081
|
+
const audio = document.getElementById('audio-player');
|
|
1082
|
+
if (!audio) return;
|
|
1083
|
+
|
|
1084
|
+
if (isPlaying) {
|
|
1085
|
+
audio.pause();
|
|
1086
|
+
} else {
|
|
1087
|
+
audio.play();
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function seekTo(time, autoPlay = true) {
|
|
1092
|
+
const audio = document.getElementById('audio-player');
|
|
1093
|
+
if (audio) {
|
|
1094
|
+
audio.currentTime = time;
|
|
1095
|
+
currentTime = time;
|
|
1096
|
+
updatePlayhead();
|
|
1097
|
+
// Auto-play when seeking via click (if not already playing)
|
|
1098
|
+
if (autoPlay && !isPlaying) {
|
|
1099
|
+
audio.play();
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function onTimeUpdate(e) {
|
|
1105
|
+
currentTime = e.target.currentTime;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
function setActiveAudio(type) {
|
|
1110
|
+
const audio = document.getElementById('audio-player');
|
|
1111
|
+
if (!audio) return;
|
|
1112
|
+
|
|
1113
|
+
const wasPlaying = !audio.paused;
|
|
1114
|
+
const time = audio.currentTime;
|
|
1115
|
+
|
|
1116
|
+
// Pause before changing source
|
|
1117
|
+
audio.pause();
|
|
1118
|
+
|
|
1119
|
+
activeAudio = type;
|
|
1120
|
+
|
|
1121
|
+
// Update toggle button states using data attributes (robust detection)
|
|
1122
|
+
document.querySelectorAll('.audio-toggle').forEach(btn => {
|
|
1123
|
+
const btnType = btn.dataset.audioType || 'custom';
|
|
1124
|
+
btn.classList.toggle('active', btnType === type);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// Change source and restore playback
|
|
1128
|
+
audio.src = getAudioUrl();
|
|
1129
|
+
audio.addEventListener('loadeddata', function onLoaded() {
|
|
1130
|
+
audio.currentTime = time;
|
|
1131
|
+
if (wasPlaying) {
|
|
1132
|
+
audio.play().catch(() => {});
|
|
1133
|
+
}
|
|
1134
|
+
}, { once: true });
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function getAudioUrl() {
|
|
1138
|
+
const stemTypes = {
|
|
1139
|
+
original: 'original',
|
|
1140
|
+
backing: 'backing_vocals',
|
|
1141
|
+
clean: 'clean_instrumental',
|
|
1142
|
+
with_backing: 'with_backing',
|
|
1143
|
+
custom: 'custom_instrumental',
|
|
1144
|
+
uploaded: 'uploaded_instrumental'
|
|
1145
|
+
};
|
|
1146
|
+
const stemType = stemTypes[activeAudio] || stemTypes.backing;
|
|
1147
|
+
|
|
1148
|
+
// Cloud mode uses /audio-stream/{stem_type}, local mode uses /api/audio/{stem_type}
|
|
1149
|
+
const isCloudMode = !!encodedBaseApiUrl;
|
|
1150
|
+
const url = isCloudMode
|
|
1151
|
+
? `${API_BASE}/audio-stream/${stemType}`
|
|
1152
|
+
: `/api/audio/${stemType}`;
|
|
1153
|
+
|
|
1154
|
+
return addTokenToUrl(url);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function formatTime(seconds) {
|
|
1158
|
+
const mins = Math.floor(seconds / 60);
|
|
1159
|
+
const secs = Math.floor(seconds % 60);
|
|
1160
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function addRegion(start, end) {
|
|
1164
|
+
muteRegions.push({ start_seconds: start, end_seconds: end });
|
|
1165
|
+
muteRegions.sort((a, b) => a.start_seconds - b.start_seconds);
|
|
1166
|
+
mergeOverlappingRegions();
|
|
1167
|
+
hasCustom = false; // Invalidate custom when regions change
|
|
1168
|
+
|
|
1169
|
+
// Just redraw waveform instead of full render to preserve scroll position
|
|
1170
|
+
drawWaveform();
|
|
1171
|
+
updateMuteRegionsPanel();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function updateMuteRegionsPanel() {
|
|
1175
|
+
// Update only the mute regions panel without full DOM rebuild
|
|
1176
|
+
const panel = document.querySelector('.mute-panel');
|
|
1177
|
+
if (!panel) return;
|
|
1178
|
+
|
|
1179
|
+
const segments = analysisData.analysis.audible_segments;
|
|
1180
|
+
const hasSegments = segments.length > 0;
|
|
1181
|
+
|
|
1182
|
+
// Build mute regions list HTML
|
|
1183
|
+
let regionsListHtml = '';
|
|
1184
|
+
if (muteRegions.length > 0) {
|
|
1185
|
+
regionsListHtml = '<div class="mute-regions-list">' +
|
|
1186
|
+
muteRegions.map((r, i) =>
|
|
1187
|
+
'<div class="mute-region-tag">' +
|
|
1188
|
+
'<span onclick="seekTo(' + r.start_seconds + ', true)" style="cursor: pointer">' +
|
|
1189
|
+
formatTime(r.start_seconds) + ' – ' + formatTime(r.end_seconds) + '</span>' +
|
|
1190
|
+
'<button onclick="removeRegion(' + i + ')">×</button>' +
|
|
1191
|
+
'</div>'
|
|
1192
|
+
).join('') +
|
|
1193
|
+
'</div>';
|
|
1194
|
+
} else {
|
|
1195
|
+
regionsListHtml = '<div style="color: var(--text-muted); font-size: 0.75rem;">' +
|
|
1196
|
+
(hasSegments ? 'Click segments below or <kbd class="kbd">Shift</kbd> + drag on waveform' : 'No backing vocals detected – clean instrumental recommended') +
|
|
1197
|
+
'</div>';
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Build quick segments HTML
|
|
1201
|
+
let quickSegmentsHtml = '';
|
|
1202
|
+
if (hasSegments) {
|
|
1203
|
+
quickSegmentsHtml = '<div class="quick-segments">' +
|
|
1204
|
+
segments.slice(0, 8).map((seg, i) =>
|
|
1205
|
+
'<button class="quick-segment-btn" onclick="addSegmentAsRegion(' + i + ')" title="Add to mute regions">' +
|
|
1206
|
+
formatTime(seg.start_seconds) + ' – ' + formatTime(seg.end_seconds) +
|
|
1207
|
+
'</button>'
|
|
1208
|
+
).join('') +
|
|
1209
|
+
(segments.length > 8 ? '<span style="font-size: 0.7rem; color: var(--text-muted); padding: 3px;">+' + (segments.length - 8) + ' more</span>' : '') +
|
|
1210
|
+
'</div>';
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Build header buttons
|
|
1214
|
+
let headerButtons = '';
|
|
1215
|
+
if (muteRegions.length > 0) {
|
|
1216
|
+
headerButtons = '<div style="display: flex; gap: 6px;">' +
|
|
1217
|
+
'<button class="btn btn-sm btn-secondary" onclick="clearAllRegions()">Clear</button>' +
|
|
1218
|
+
(!hasCustom ? '<button class="btn btn-sm btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">Create Custom</button>' : '') +
|
|
1219
|
+
'</div>';
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
panel.innerHTML =
|
|
1223
|
+
'<div class="mute-panel-header">' +
|
|
1224
|
+
'<span class="mute-panel-title">Mute Regions ' + (muteRegions.length > 0 ? '(' + muteRegions.length + ')' : '') + '</span>' +
|
|
1225
|
+
headerButtons +
|
|
1226
|
+
'</div>' +
|
|
1227
|
+
regionsListHtml +
|
|
1228
|
+
quickSegmentsHtml;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function addSegmentAsRegion(index) {
|
|
1232
|
+
const seg = analysisData.analysis.audible_segments[index];
|
|
1233
|
+
if (seg) {
|
|
1234
|
+
addRegion(seg.start_seconds, seg.end_seconds);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function removeRegion(index) {
|
|
1239
|
+
muteRegions.splice(index, 1);
|
|
1240
|
+
hasCustom = false;
|
|
1241
|
+
drawWaveform();
|
|
1242
|
+
updateMuteRegionsPanel();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function clearAllRegions() {
|
|
1246
|
+
muteRegions = [];
|
|
1247
|
+
hasCustom = false;
|
|
1248
|
+
drawWaveform();
|
|
1249
|
+
updateMuteRegionsPanel();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function mergeOverlappingRegions() {
|
|
1253
|
+
if (muteRegions.length < 2) return;
|
|
1254
|
+
|
|
1255
|
+
const merged = [muteRegions[0]];
|
|
1256
|
+
for (let i = 1; i < muteRegions.length; i++) {
|
|
1257
|
+
const last = merged[merged.length - 1];
|
|
1258
|
+
const curr = muteRegions[i];
|
|
1259
|
+
|
|
1260
|
+
if (curr.start_seconds <= last.end_seconds + 0.5) {
|
|
1261
|
+
last.end_seconds = Math.max(last.end_seconds, curr.end_seconds);
|
|
1262
|
+
} else {
|
|
1263
|
+
merged.push(curr);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
muteRegions = merged;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function setSelection(value) {
|
|
1270
|
+
selectedOption = value;
|
|
1271
|
+
render();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function setZoom(level) {
|
|
1275
|
+
const container = document.getElementById('waveform-container');
|
|
1276
|
+
const oldScrollRatio = container ? container.scrollLeft / (container.scrollWidth - container.clientWidth || 1) : 0;
|
|
1277
|
+
|
|
1278
|
+
zoomLevel = level;
|
|
1279
|
+
|
|
1280
|
+
// Update zoom button states directly (avoid full render)
|
|
1281
|
+
document.querySelectorAll('.zoom-btn').forEach(btn => {
|
|
1282
|
+
const btnLevel = parseInt(btn.textContent);
|
|
1283
|
+
btn.classList.toggle('active', btnLevel === level);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// Resize canvas and redraw
|
|
1287
|
+
resizeCanvas();
|
|
1288
|
+
drawWaveform();
|
|
1289
|
+
|
|
1290
|
+
// Maintain scroll position proportionally
|
|
1291
|
+
if (container && zoomLevel > 1) {
|
|
1292
|
+
const newMaxScroll = container.scrollWidth - container.clientWidth;
|
|
1293
|
+
container.scrollLeft = oldScrollRatio * newMaxScroll;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function handleUpload(event) {
|
|
1298
|
+
const file = event.target.files[0];
|
|
1299
|
+
if (!file) return;
|
|
1300
|
+
|
|
1301
|
+
// Show upload progress
|
|
1302
|
+
const overlay = document.createElement('div');
|
|
1303
|
+
overlay.className = 'upload-overlay';
|
|
1304
|
+
overlay.id = 'upload-overlay';
|
|
1305
|
+
document.body.appendChild(overlay);
|
|
1306
|
+
|
|
1307
|
+
const progress = document.createElement('div');
|
|
1308
|
+
progress.className = 'upload-progress';
|
|
1309
|
+
progress.id = 'upload-progress';
|
|
1310
|
+
progress.innerHTML = `
|
|
1311
|
+
<div class="spinner" style="margin: 0 auto 12px;"></div>
|
|
1312
|
+
<div>Uploading ${file.name}...</div>
|
|
1313
|
+
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 8px;">Validating duration...</div>
|
|
1314
|
+
`;
|
|
1315
|
+
document.body.appendChild(progress);
|
|
1316
|
+
|
|
1317
|
+
try {
|
|
1318
|
+
const formData = new FormData();
|
|
1319
|
+
formData.append('file', file);
|
|
1320
|
+
|
|
1321
|
+
const response = await fetch(addTokenToUrl(`${API_BASE}/upload-instrumental`), {
|
|
1322
|
+
method: 'POST',
|
|
1323
|
+
body: formData
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
if (!response.ok) {
|
|
1327
|
+
const data = await response.json();
|
|
1328
|
+
throw new Error(data.detail || 'Upload failed');
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const result = await response.json();
|
|
1332
|
+
hasUploaded = true;
|
|
1333
|
+
uploadedFilename = file.name;
|
|
1334
|
+
activeAudio = 'uploaded';
|
|
1335
|
+
selectedOption = 'uploaded';
|
|
1336
|
+
|
|
1337
|
+
render();
|
|
1338
|
+
showSuccess(`Uploaded ${file.name} (${result.duration_seconds.toFixed(1)}s)`);
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
showError(error.message);
|
|
1341
|
+
} finally {
|
|
1342
|
+
// Clean up progress UI
|
|
1343
|
+
document.getElementById('upload-overlay')?.remove();
|
|
1344
|
+
document.getElementById('upload-progress')?.remove();
|
|
1345
|
+
// Reset file input so same file can be uploaded again
|
|
1346
|
+
event.target.value = '';
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function showSuccess(message) {
|
|
1351
|
+
const existing = document.querySelector('.alert-success-toast');
|
|
1352
|
+
if (existing) existing.remove();
|
|
1353
|
+
|
|
1354
|
+
const el = document.createElement('div');
|
|
1355
|
+
el.className = 'alert-error'; // Reuse error styling but green
|
|
1356
|
+
el.style.background = 'rgba(34, 197, 94, 0.95)';
|
|
1357
|
+
el.textContent = message;
|
|
1358
|
+
document.body.appendChild(el);
|
|
1359
|
+
|
|
1360
|
+
setTimeout(() => el.remove(), 3000);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function createCustomInstrumental() {
|
|
1364
|
+
const btn = document.getElementById('create-custom-btn');
|
|
1365
|
+
const audio = document.getElementById('audio-player');
|
|
1366
|
+
const wasPlaying = isPlaying;
|
|
1367
|
+
const time = currentTime;
|
|
1368
|
+
|
|
1369
|
+
// Pause audio while creating custom instrumental
|
|
1370
|
+
if (audio && !audio.paused) {
|
|
1371
|
+
audio.pause();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (btn) {
|
|
1375
|
+
btn.disabled = true;
|
|
1376
|
+
btn.textContent = 'Creating...';
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
try {
|
|
1380
|
+
const response = await fetch(addTokenToUrl(`${API_BASE}/create-custom-instrumental`), {
|
|
1381
|
+
method: 'POST',
|
|
1382
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1383
|
+
body: JSON.stringify({ mute_regions: muteRegions })
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
if (!response.ok) {
|
|
1387
|
+
const data = await response.json();
|
|
1388
|
+
throw new Error(data.detail || 'Failed to create custom');
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
hasCustom = true;
|
|
1392
|
+
selectedOption = 'custom';
|
|
1393
|
+
activeAudio = 'custom';
|
|
1394
|
+
|
|
1395
|
+
// Render first, then restore playback
|
|
1396
|
+
render();
|
|
1397
|
+
|
|
1398
|
+
// After render, seek to previous position and optionally resume
|
|
1399
|
+
const newAudio = document.getElementById('audio-player');
|
|
1400
|
+
if (newAudio) {
|
|
1401
|
+
newAudio.addEventListener('loadeddata', function onLoaded() {
|
|
1402
|
+
newAudio.removeEventListener('loadeddata', onLoaded);
|
|
1403
|
+
newAudio.currentTime = time;
|
|
1404
|
+
if (wasPlaying) {
|
|
1405
|
+
newAudio.play().catch(() => {});
|
|
1406
|
+
}
|
|
1407
|
+
}, { once: true });
|
|
1408
|
+
}
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
showError(error.message);
|
|
1411
|
+
if (btn) {
|
|
1412
|
+
btn.disabled = false;
|
|
1413
|
+
btn.textContent = 'Create Custom';
|
|
1414
|
+
}
|
|
1415
|
+
// Resume playback if there was an error
|
|
1416
|
+
if (wasPlaying && audio) {
|
|
1417
|
+
audio.play().catch(() => {});
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function submitSelection() {
|
|
1423
|
+
const btn = document.getElementById('submit-btn');
|
|
1424
|
+
if (btn) {
|
|
1425
|
+
btn.disabled = true;
|
|
1426
|
+
btn.textContent = 'Submitting...';
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
try {
|
|
1430
|
+
const response = await fetch(addTokenToUrl(`${API_BASE}/select-instrumental`), {
|
|
1431
|
+
method: 'POST',
|
|
1432
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1433
|
+
body: JSON.stringify({ selection: selectedOption })
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
if (!response.ok) {
|
|
1437
|
+
const data = await response.json();
|
|
1438
|
+
throw new Error(data.detail || 'Failed to submit');
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const selectionLabels = {
|
|
1442
|
+
clean: 'Clean Instrumental',
|
|
1443
|
+
with_backing: 'With Backing Vocals',
|
|
1444
|
+
custom: 'Custom',
|
|
1445
|
+
uploaded: 'Uploaded Instrumental',
|
|
1446
|
+
original: 'Original Audio'
|
|
1447
|
+
};
|
|
1448
|
+
const selectionLabel = selectionLabels[selectedOption] || selectedOption;
|
|
1449
|
+
|
|
1450
|
+
document.getElementById('app').innerHTML = `
|
|
1451
|
+
<div class="success-screen">
|
|
1452
|
+
<h2>✓ Selection Submitted</h2>
|
|
1453
|
+
<p>You selected: <strong>${escapeHtml(selectionLabel)}</strong></p>
|
|
1454
|
+
<p id="close-msg" style="color: var(--text-muted);">Closing in <span id="countdown">2</span>s...</p>
|
|
1455
|
+
</div>
|
|
1456
|
+
`;
|
|
1457
|
+
|
|
1458
|
+
// Auto-close window after 2 seconds
|
|
1459
|
+
let countdown = 2;
|
|
1460
|
+
const countdownEl = document.getElementById('countdown');
|
|
1461
|
+
const countdownInterval = setInterval(() => {
|
|
1462
|
+
countdown--;
|
|
1463
|
+
if (countdownEl) countdownEl.textContent = countdown;
|
|
1464
|
+
if (countdown <= 0) {
|
|
1465
|
+
clearInterval(countdownInterval);
|
|
1466
|
+
// Try to close the window (works for windows opened by script)
|
|
1467
|
+
window.close();
|
|
1468
|
+
// If window.close() didn't work, update message
|
|
1469
|
+
const msg = document.getElementById('close-msg');
|
|
1470
|
+
if (msg) msg.textContent = 'You can close this window now.';
|
|
1471
|
+
}
|
|
1472
|
+
}, 1000);
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
showError(error.message);
|
|
1475
|
+
if (btn) {
|
|
1476
|
+
btn.disabled = false;
|
|
1477
|
+
btn.textContent = '✓ Confirm & Continue';
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function setupKeyboardShortcuts() {
|
|
1483
|
+
document.addEventListener('keydown', (e) => {
|
|
1484
|
+
// Ignore if typing in input
|
|
1485
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
1486
|
+
|
|
1487
|
+
switch (e.code) {
|
|
1488
|
+
case 'Space':
|
|
1489
|
+
e.preventDefault();
|
|
1490
|
+
togglePlayPause();
|
|
1491
|
+
break;
|
|
1492
|
+
case 'Escape':
|
|
1493
|
+
// Cancel any in-progress drag selection
|
|
1494
|
+
if (isDragging) {
|
|
1495
|
+
isDragging = false;
|
|
1496
|
+
showSelectionOverlay(false);
|
|
1497
|
+
}
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function showError(message) {
|
|
1504
|
+
// Remove any existing error
|
|
1505
|
+
const existing = document.querySelector('.alert-error');
|
|
1506
|
+
if (existing) existing.remove();
|
|
1507
|
+
|
|
1508
|
+
const errorEl = document.createElement('div');
|
|
1509
|
+
errorEl.className = 'alert-error';
|
|
1510
|
+
errorEl.textContent = message;
|
|
1511
|
+
document.body.appendChild(errorEl);
|
|
1512
|
+
|
|
1513
|
+
setTimeout(() => errorEl.remove(), 5000);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Handle window resize
|
|
1517
|
+
window.addEventListener('resize', () => {
|
|
1518
|
+
if (waveformData) {
|
|
1519
|
+
resizeCanvas();
|
|
1520
|
+
drawWaveform();
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
// Start
|
|
1525
|
+
init();
|
|
1526
|
+
</script>
|
|
1527
|
+
</body>
|
|
1528
|
+
</html>
|
|
1529
|
+
|