lyrics-transcriber 0.43.0__py3-none-any.whl → 0.44.0__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 (50) hide show
  1. lyrics_transcriber/core/controller.py +58 -24
  2. lyrics_transcriber/correction/anchor_sequence.py +22 -8
  3. lyrics_transcriber/correction/corrector.py +47 -3
  4. lyrics_transcriber/correction/handlers/llm.py +15 -12
  5. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  6. lyrics_transcriber/frontend/.yarn/install-state.gz +0 -0
  7. lyrics_transcriber/frontend/dist/assets/{index-D0Gr3Ep7.js → index-DVoI6Z16.js} +10799 -7490
  8. lyrics_transcriber/frontend/dist/assets/index-DVoI6Z16.js.map +1 -0
  9. lyrics_transcriber/frontend/dist/index.html +1 -1
  10. lyrics_transcriber/frontend/src/App.tsx +4 -4
  11. lyrics_transcriber/frontend/src/api.ts +37 -0
  12. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  13. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +14 -10
  14. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +62 -56
  15. lyrics_transcriber/frontend/src/components/EditModal.tsx +232 -237
  16. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  17. lyrics_transcriber/frontend/src/components/GlobalSyncEditor.tsx +675 -0
  18. lyrics_transcriber/frontend/src/components/Header.tsx +141 -101
  19. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +146 -80
  20. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +22 -13
  21. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +1 -0
  22. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +29 -12
  23. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +21 -4
  24. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +29 -15
  25. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +34 -16
  26. lyrics_transcriber/frontend/src/components/WordDivider.tsx +186 -0
  27. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +89 -41
  28. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +9 -2
  29. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +28 -3
  30. lyrics_transcriber/frontend/src/components/shared/types.ts +17 -2
  31. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +63 -14
  32. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +192 -0
  33. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +267 -0
  34. lyrics_transcriber/frontend/src/main.tsx +7 -1
  35. lyrics_transcriber/frontend/src/theme.ts +177 -0
  36. lyrics_transcriber/frontend/src/types.ts +1 -1
  37. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  38. lyrics_transcriber/lyrics/base_lyrics_provider.py +2 -2
  39. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  40. lyrics_transcriber/output/generator.py +40 -12
  41. lyrics_transcriber/output/video.py +18 -8
  42. lyrics_transcriber/review/server.py +238 -8
  43. {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/METADATA +3 -2
  44. {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/RECORD +47 -41
  45. lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.js.map +0 -1
  46. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +0 -252
  47. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +0 -110
  48. {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/LICENSE +0 -0
  49. {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/WHEEL +0 -0
  50. {lyrics_transcriber-0.43.0.dist-info → lyrics_transcriber-0.44.0.dist-info}/entry_points.txt +0 -0
@@ -9,14 +9,15 @@ interface TimelineEditorProps {
9
9
  onWordUpdate: (index: number, updates: Partial<Word>) => void
10
10
  currentTime?: number
11
11
  onPlaySegment?: (time: number) => void
12
+ showPlaybackIndicator?: boolean
12
13
  }
13
14
 
14
15
  const TimelineContainer = styled(Box)(({ theme }) => ({
15
16
  position: 'relative',
16
- height: '80px',
17
+ height: '75px',
17
18
  backgroundColor: theme.palette.grey[200],
18
19
  borderRadius: theme.shape.borderRadius,
19
- margin: theme.spacing(2, 0),
20
+ margin: theme.spacing(1, 0),
20
21
  padding: theme.spacing(0, 1),
21
22
  }))
22
23
 
@@ -68,6 +69,7 @@ const TimelineWord = styled(Box)(({ theme }) => ({
68
69
  fontSize: '0.875rem',
69
70
  fontFamily: 'sans-serif',
70
71
  transition: 'background-color 0.1s ease',
72
+ boxSizing: 'border-box',
71
73
  '&.highlighted': {
72
74
  backgroundColor: theme.palette.secondary.main,
73
75
  }
@@ -76,17 +78,27 @@ const TimelineWord = styled(Box)(({ theme }) => ({
76
78
  const ResizeHandle = styled(Box)(({ theme }) => ({
77
79
  position: 'absolute',
78
80
  top: 0,
79
- width: 8,
81
+ width: 10,
80
82
  height: '100%',
81
83
  cursor: 'col-resize',
82
84
  '&:hover': {
83
85
  backgroundColor: theme.palette.primary.light,
86
+ opacity: 0.8,
87
+ boxShadow: `0 0 0 1px ${theme.palette.primary.dark}`,
84
88
  },
85
89
  '&.left': {
86
- left: -4,
90
+ left: 0,
91
+ right: 'auto',
92
+ paddingRight: 0,
93
+ borderTopLeftRadius: theme.shape.borderRadius,
94
+ borderBottomLeftRadius: theme.shape.borderRadius,
87
95
  },
88
96
  '&.right': {
89
- right: -4,
97
+ right: 0,
98
+ left: 'auto',
99
+ paddingLeft: 0,
100
+ borderTopRightRadius: theme.shape.borderRadius,
101
+ borderBottomRightRadius: theme.shape.borderRadius,
90
102
  }
91
103
  }))
92
104
 
@@ -102,7 +114,7 @@ const TimelineCursor = styled(Box)(({ theme }) => ({
102
114
  zIndex: 1, // Ensure it's above other elements
103
115
  }))
104
116
 
105
- export default function TimelineEditor({ words, startTime, endTime, onWordUpdate, currentTime = 0, onPlaySegment }: TimelineEditorProps) {
117
+ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate, currentTime = 0, onPlaySegment, showPlaybackIndicator = true }: TimelineEditorProps) {
106
118
  const containerRef = useRef<HTMLDivElement>(null)
107
119
  const [dragState, setDragState] = useState<{
108
120
  wordIndex: number
@@ -297,12 +309,14 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
297
309
  </TimelineRuler>
298
310
 
299
311
  {/* Add cursor line */}
300
- <TimelineCursor
301
- sx={{
302
- left: `${timeToPosition(currentTime)}%`,
303
- display: currentTime >= startTime && currentTime <= endTime ? 'block' : 'none'
304
- }}
305
- />
312
+ {showPlaybackIndicator && (
313
+ <TimelineCursor
314
+ sx={{
315
+ left: `${timeToPosition(currentTime)}%`,
316
+ display: currentTime >= startTime && currentTime <= endTime ? 'block' : 'none'
317
+ }}
318
+ />
319
+ )}
306
320
 
307
321
  {words.map((word, index) => {
308
322
  // Skip words with null timestamps
@@ -311,8 +325,8 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
311
325
  const leftPosition = timeToPosition(word.start_time)
312
326
  const rightPosition = timeToPosition(word.end_time)
313
327
  const width = rightPosition - leftPosition
314
- const visualPadding = 2
315
- const adjustedWidth = Math.max(0, width - visualPadding)
328
+ // Remove the visual padding that creates gaps
329
+ const adjustedWidth = width
316
330
 
317
331
  return (
318
332
  <TimelineWord
@@ -321,7 +335,7 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
321
335
  sx={{
322
336
  left: `${leftPosition}%`,
323
337
  width: `${adjustedWidth}%`,
324
- maxWidth: `calc(${100 - leftPosition}% - 2px)`,
338
+ maxWidth: `calc(${100 - leftPosition}%)`,
325
339
  }}
326
340
  onMouseDown={(e) => {
327
341
  e.stopPropagation()
@@ -9,14 +9,16 @@ import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
9
9
 
10
10
  const SegmentIndex = styled(Typography)(({ theme }) => ({
11
11
  color: theme.palette.text.secondary,
12
- width: '2em',
13
- minWidth: '2em',
12
+ width: '1.8em',
13
+ minWidth: '1.8em',
14
14
  textAlign: 'right',
15
- marginRight: theme.spacing(1),
15
+ marginRight: theme.spacing(0.8),
16
16
  userSelect: 'none',
17
17
  fontFamily: 'monospace',
18
18
  cursor: 'pointer',
19
- paddingTop: '3px',
19
+ paddingTop: '1px',
20
+ fontSize: '0.8rem',
21
+ lineHeight: 1.2,
20
22
  '&:hover': {
21
23
  textDecoration: 'underline',
22
24
  },
@@ -30,10 +32,10 @@ const TextContainer = styled(Box)({
30
32
  const SegmentControls = styled(Box)({
31
33
  display: 'flex',
32
34
  alignItems: 'center',
33
- gap: '4px',
34
- minWidth: '3em',
35
- paddingTop: '3px',
36
- paddingRight: '8px'
35
+ gap: '2px',
36
+ minWidth: '2.5em',
37
+ paddingTop: '1px',
38
+ paddingRight: '4px'
37
39
  })
38
40
 
39
41
  export default function TranscriptionView({
@@ -51,11 +53,13 @@ export default function TranscriptionView({
51
53
  const [selectedSegmentIndex, setSelectedSegmentIndex] = useState<number | null>(null)
52
54
 
53
55
  return (
54
- <Paper sx={{ p: 2 }}>
55
- <Typography variant="h6" gutterBottom>
56
- Corrected Transcription
57
- </Typography>
58
- <Box sx={{ display: 'flex', flexDirection: 'column' }}>
56
+ <Paper sx={{ p: 0.8 }}>
57
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
58
+ <Typography variant="h6" sx={{ fontSize: '0.9rem', mb: 0 }}>
59
+ Corrected Transcription
60
+ </Typography>
61
+ </Box>
62
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.2 }}>
59
63
  {data.corrected_segments.map((segment, segmentIndex) => {
60
64
  const segmentWords: TranscriptionWordPosition[] = segment.words.map(word => {
61
65
  // Find if this word is part of a correction
@@ -105,7 +109,15 @@ export default function TranscriptionView({
105
109
  })
106
110
 
107
111
  return (
108
- <Box key={segment.id} sx={{ display: 'flex', alignItems: 'flex-start', width: '100%' }}>
112
+ <Box key={segment.id} sx={{
113
+ display: 'flex',
114
+ alignItems: 'flex-start',
115
+ width: '100%',
116
+ mb: 0,
117
+ '&:hover': {
118
+ backgroundColor: 'rgba(0, 0, 0, 0.03)'
119
+ }
120
+ }}>
109
121
  <SegmentControls>
110
122
  <SegmentIndex
111
123
  variant="body2"
@@ -117,9 +129,15 @@ export default function TranscriptionView({
117
129
  <IconButton
118
130
  size="small"
119
131
  onClick={() => onPlaySegment?.(segment.start_time!)}
120
- sx={{ padding: '2px' }}
132
+ sx={{
133
+ padding: '1px',
134
+ height: '18px',
135
+ width: '18px',
136
+ minHeight: '18px',
137
+ minWidth: '18px'
138
+ }}
121
139
  >
122
- <PlayCircleOutlineIcon fontSize="small" />
140
+ <PlayCircleOutlineIcon sx={{ fontSize: '0.9rem' }} />
123
141
  </IconButton>
124
142
  )}
125
143
  </SegmentControls>
@@ -0,0 +1,186 @@
1
+ import { Box, Button, Typography } from '@mui/material'
2
+ import AddIcon from '@mui/icons-material/Add'
3
+ import MergeIcon from '@mui/icons-material/CallMerge'
4
+ import CallSplitIcon from '@mui/icons-material/CallSplit'
5
+
6
+ interface WordDividerProps {
7
+ onAddWord: () => void
8
+ onMergeWords?: () => void
9
+ onAddSegmentBefore?: () => void
10
+ onAddSegmentAfter?: () => void
11
+ onSplitSegment?: () => void
12
+ onMergeSegment?: () => void
13
+ canMerge?: boolean
14
+ isFirst?: boolean
15
+ isLast?: boolean
16
+ sx?: any
17
+ }
18
+
19
+ const buttonTextStyle = {
20
+ color: 'rgba(0, 0, 0, 0.6)',
21
+ fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
22
+ fontWeight: 400,
23
+ fontSize: '0.7rem',
24
+ lineHeight: '1.4375em',
25
+ textTransform: 'none'
26
+ }
27
+
28
+ const buttonBaseStyle = {
29
+ minHeight: 0,
30
+ padding: '2px 8px',
31
+ '& .MuiButton-startIcon': {
32
+ marginRight: 0.5
33
+ },
34
+ '& .MuiSvgIcon-root': {
35
+ fontSize: '1.2rem'
36
+ }
37
+ }
38
+
39
+ export default function WordDivider({
40
+ onAddWord,
41
+ onMergeWords,
42
+ onAddSegmentBefore,
43
+ onAddSegmentAfter,
44
+ onSplitSegment,
45
+ onMergeSegment,
46
+ canMerge = false,
47
+ isFirst = false,
48
+ isLast = false,
49
+ sx = {}
50
+ }: WordDividerProps) {
51
+ return (
52
+ <Box
53
+ sx={{
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'center',
57
+ height: '20px',
58
+ my: -0.5,
59
+ width: '50%',
60
+ backgroundColor: '#fff',
61
+ ...sx
62
+ }}
63
+ >
64
+ <Box sx={{
65
+ display: 'flex',
66
+ alignItems: 'center',
67
+ gap: 1,
68
+ backgroundColor: '#fff',
69
+ padding: '0 8px',
70
+ zIndex: 1
71
+ }}>
72
+ <Button
73
+ onClick={onAddWord}
74
+ title="Add Word"
75
+ size="small"
76
+ startIcon={<AddIcon />}
77
+ sx={{
78
+ ...buttonBaseStyle,
79
+ color: 'primary.main',
80
+ }}
81
+ >
82
+ <Typography sx={buttonTextStyle}>
83
+ Add Word
84
+ </Typography>
85
+ </Button>
86
+ {isFirst && (
87
+ <>
88
+ <Button
89
+ onClick={onAddSegmentBefore}
90
+ title="Add Segment"
91
+ size="small"
92
+ startIcon={<AddIcon sx={{ transform: 'rotate(90deg)' }} />}
93
+ sx={{
94
+ ...buttonBaseStyle,
95
+ color: 'success.main',
96
+ }}
97
+ >
98
+ <Typography sx={buttonTextStyle}>
99
+ Add Segment
100
+ </Typography>
101
+ </Button>
102
+ <Button
103
+ onClick={onMergeSegment}
104
+ title="Merge with Previous Segment"
105
+ size="small"
106
+ startIcon={<MergeIcon sx={{ transform: 'rotate(90deg)' }} />}
107
+ sx={{
108
+ ...buttonBaseStyle,
109
+ color: 'warning.main',
110
+ }}
111
+ >
112
+ <Typography sx={buttonTextStyle}>
113
+ Merge Segment
114
+ </Typography>
115
+ </Button>
116
+ </>
117
+ )}
118
+ {onMergeWords && !isLast && (
119
+ <Button
120
+ onClick={onMergeWords}
121
+ title="Merge Words"
122
+ size="small"
123
+ startIcon={<MergeIcon sx={{ transform: 'rotate(90deg)' }} />}
124
+ disabled={!canMerge}
125
+ sx={{
126
+ ...buttonBaseStyle,
127
+ color: 'primary.main',
128
+ }}
129
+ >
130
+ <Typography sx={buttonTextStyle}>
131
+ Merge Words
132
+ </Typography>
133
+ </Button>
134
+ )}
135
+ {onSplitSegment && !isLast && (
136
+ <Button
137
+ onClick={onSplitSegment}
138
+ title="Split Segment"
139
+ size="small"
140
+ startIcon={<CallSplitIcon sx={{ transform: 'rotate(90deg)' }} />}
141
+ sx={{
142
+ ...buttonBaseStyle,
143
+ color: 'warning.main',
144
+ }}
145
+ >
146
+ <Typography sx={buttonTextStyle}>
147
+ Split Segment
148
+ </Typography>
149
+ </Button>
150
+ )}
151
+ {isLast && (
152
+ <>
153
+ <Button
154
+ onClick={onAddSegmentAfter}
155
+ title="Add Segment"
156
+ size="small"
157
+ startIcon={<AddIcon sx={{ transform: 'rotate(90deg)' }} />}
158
+ sx={{
159
+ ...buttonBaseStyle,
160
+ color: 'success.main',
161
+ }}
162
+ >
163
+ <Typography sx={buttonTextStyle}>
164
+ Add Segment
165
+ </Typography>
166
+ </Button>
167
+ <Button
168
+ onClick={onMergeSegment}
169
+ title="Merge with Next Segment"
170
+ size="small"
171
+ startIcon={<MergeIcon sx={{ transform: 'rotate(90deg)' }} />}
172
+ sx={{
173
+ ...buttonBaseStyle,
174
+ color: 'warning.main',
175
+ }}
176
+ >
177
+ <Typography sx={buttonTextStyle}>
178
+ Merge Segment
179
+ </Typography>
180
+ </Button>
181
+ </>
182
+ )}
183
+ </Box>
184
+ </Box>
185
+ )
186
+ }
@@ -173,13 +173,28 @@ export function HighlightedText({
173
173
  wordPos.type === 'anchor' ? wordPos.sequence as AnchorSequence : undefined,
174
174
  wordPos.type === 'gap' ? wordPos.sequence as GapSequence : undefined
175
175
  )}
176
+ correction={(() => {
177
+ const correction = corrections?.find(c =>
178
+ c.corrected_word_id === wordPos.word.id ||
179
+ c.word_id === wordPos.word.id
180
+ );
181
+ return correction ? {
182
+ originalWord: correction.original_word,
183
+ handler: correction.handler,
184
+ confidence: correction.confidence
185
+ } : null;
186
+ })()}
176
187
  />
177
188
  {index < wordPositions.length - 1 && ' '}
178
189
  </React.Fragment>
179
190
  ))
180
191
  } else if (segments) {
181
192
  return segments.map((segment) => (
182
- <Box key={segment.id} sx={{ display: 'flex', alignItems: 'flex-start' }}>
193
+ <Box key={segment.id} sx={{
194
+ display: 'flex',
195
+ alignItems: 'flex-start',
196
+ mb: 0
197
+ }}>
183
198
  <Box sx={{ flex: 1 }}>
184
199
  {segment.words.map((word, wordIndex) => {
185
200
  const wordPos = wordPositions.find((pos: TranscriptionWordPosition) =>
@@ -195,6 +210,18 @@ export function HighlightedText({
195
210
 
196
211
  const sequence = wordPos?.type === 'gap' ? wordPos.sequence as GapSequence : undefined;
197
212
 
213
+ // Find correction information for the tooltip
214
+ const correction = corrections?.find(c =>
215
+ c.corrected_word_id === word.id ||
216
+ c.word_id === word.id
217
+ );
218
+
219
+ const correctionInfo = correction ? {
220
+ originalWord: correction.original_word,
221
+ handler: correction.handler,
222
+ confidence: correction.confidence
223
+ } : null;
224
+
198
225
  return (
199
226
  <React.Fragment key={word.id}>
200
227
  <WordComponent
@@ -205,6 +232,7 @@ export function HighlightedText({
205
232
  isUncorrectedGap={isUncorrectedGap}
206
233
  isCurrentlyPlaying={shouldHighlightWord(wordPos || { word: word.text, id: word.id })}
207
234
  onClick={() => handleWordClick(word.text, word.id, anchor, sequence)}
235
+ correction={correctionInfo}
208
236
  />
209
237
  {wordIndex < segment.words.length - 1 && ' '}
210
238
  </React.Fragment>
@@ -222,7 +250,12 @@ export function HighlightedText({
222
250
  if (currentLinePosition?.isEmpty) {
223
251
  wordCount++
224
252
  return (
225
- <Box key={`empty-${lineIndex}`} sx={{ display: 'flex', alignItems: 'flex-start' }}>
253
+ <Box key={`empty-${lineIndex}`} sx={{
254
+ display: 'flex',
255
+ alignItems: 'flex-start',
256
+ mb: 0,
257
+ lineHeight: 1
258
+ }}>
226
259
  <Typography
227
260
  component="span"
228
261
  sx={{
@@ -233,20 +266,58 @@ export function HighlightedText({
233
266
  marginRight: 1,
234
267
  userSelect: 'none',
235
268
  fontFamily: 'monospace',
236
- paddingTop: '4px',
269
+ paddingTop: '1px',
270
+ fontSize: '0.8rem',
271
+ lineHeight: 1
237
272
  }}
238
273
  >
239
274
  {currentLinePosition.lineNumber}
240
275
  </Typography>
241
- <Box sx={{ width: '28px' }} /> {/* Space for copy button */}
242
- <Box sx={{ flex: 1, height: '1.5em' }} />
276
+ <Box sx={{ width: '18px' }} />
277
+ <Box sx={{ flex: 1, height: '1em' }} />
243
278
  </Box>
244
279
  )
245
280
  }
246
281
 
247
- const lineContent = line.split(/(\s+)/)
282
+ const words = line.split(' ')
283
+ const lineWords: React.ReactNode[] = []
284
+
285
+ words.forEach((word, wordIndex) => {
286
+ if (word === '') return null
287
+ if (/^\s+$/.test(word)) {
288
+ return lineWords.push(<span key={`space-${lineIndex}-${wordIndex}`}> </span>)
289
+ }
290
+
291
+ const wordId = `${currentSource}-word-${wordCount}`
292
+ wordCount++
293
+
294
+ const anchor = currentSource ? anchors?.find(a =>
295
+ a.reference_word_ids[currentSource]?.includes(wordId)
296
+ ) : undefined
297
+
298
+ const hasCorrection = referenceCorrections.has(wordId)
299
+
300
+ lineWords.push(
301
+ <WordComponent
302
+ key={wordId}
303
+ word={word}
304
+ shouldFlash={shouldWordFlash({ word, id: wordId })}
305
+ isAnchor={Boolean(anchor)}
306
+ isCorrectedGap={hasCorrection}
307
+ isUncorrectedGap={false}
308
+ isCurrentlyPlaying={shouldHighlightWord({ word, id: wordId })}
309
+ onClick={() => handleWordClick(word, wordId, anchor, undefined)}
310
+ />
311
+ )
312
+ })
313
+
248
314
  return (
249
- <Box key={`line-${lineIndex}`} sx={{ display: 'flex', alignItems: 'flex-start' }}>
315
+ <Box key={`line-${lineIndex}`} sx={{
316
+ display: 'flex',
317
+ alignItems: 'flex-start',
318
+ mb: 0,
319
+ lineHeight: 1
320
+ }}>
250
321
  <Typography
251
322
  component="span"
252
323
  sx={{
@@ -257,7 +328,9 @@ export function HighlightedText({
257
328
  marginRight: 1,
258
329
  userSelect: 'none',
259
330
  fontFamily: 'monospace',
260
- paddingTop: '4px',
331
+ paddingTop: '1px',
332
+ fontSize: '0.8rem',
333
+ lineHeight: 1
261
334
  }}
262
335
  >
263
336
  {currentLinePosition?.lineNumber ?? lineIndex}
@@ -266,43 +339,18 @@ export function HighlightedText({
266
339
  size="small"
267
340
  onClick={() => handleCopyLine(line)}
268
341
  sx={{
269
- padding: '2px',
270
- marginRight: 1,
271
- height: '24px',
272
- width: '24px'
342
+ padding: '1px',
343
+ marginRight: 0.5,
344
+ height: '18px',
345
+ width: '18px',
346
+ minHeight: '18px',
347
+ minWidth: '18px'
273
348
  }}
274
349
  >
275
- <ContentCopyIcon sx={{ fontSize: '1rem' }} />
350
+ <ContentCopyIcon sx={{ fontSize: '0.9rem' }} />
276
351
  </IconButton>
277
352
  <Box sx={{ flex: 1 }}>
278
- {lineContent.map((word, wordIndex) => {
279
- if (word === '') return null
280
- if (/^\s+$/.test(word)) {
281
- return <span key={`space-${lineIndex}-${wordIndex}`}> </span>
282
- }
283
-
284
- const wordId = `${currentSource}-word-${wordCount}`
285
- wordCount++
286
-
287
- const anchor = currentSource ? anchors?.find(a =>
288
- a.reference_word_ids[currentSource]?.includes(wordId)
289
- ) : undefined
290
-
291
- const hasCorrection = referenceCorrections.has(wordId)
292
-
293
- return (
294
- <WordComponent
295
- key={wordId}
296
- word={word}
297
- shouldFlash={shouldWordFlash({ word, id: wordId })}
298
- isAnchor={Boolean(anchor)}
299
- isCorrectedGap={hasCorrection}
300
- isUncorrectedGap={false}
301
- isCurrentlyPlaying={shouldHighlightWord({ word, id: wordId })}
302
- onClick={() => handleWordClick(word, wordId, anchor, undefined)}
303
- />
304
- )
305
- })}
353
+ {lineWords}
306
354
  </Box>
307
355
  </Box>
308
356
  )
@@ -8,14 +8,21 @@ export interface SourceSelectorProps {
8
8
 
9
9
  export function SourceSelector({ currentSource, onSourceChange, availableSources }: SourceSelectorProps) {
10
10
  return (
11
- <Box>
11
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.3 }}>
12
12
  {availableSources.map((source) => (
13
13
  <Button
14
14
  key={source}
15
15
  size="small"
16
16
  variant={currentSource === source ? 'contained' : 'outlined'}
17
17
  onClick={() => onSourceChange(source)}
18
- sx={{ mr: 1 }}
18
+ sx={{
19
+ mr: 0,
20
+ py: 0.2,
21
+ px: 0.8,
22
+ minWidth: 'auto',
23
+ fontSize: '0.7rem',
24
+ lineHeight: 1.2
25
+ }}
19
26
  >
20
27
  {/* Capitalize first letter of source */}
21
28
  {source.charAt(0).toUpperCase() + source.slice(1)}
@@ -2,6 +2,7 @@ import React from 'react'
2
2
  import { COLORS } from '../constants'
3
3
  import { HighlightedWord } from '../styles'
4
4
  import { WordProps } from '../types'
5
+ import { Tooltip } from '@mui/material'
5
6
 
6
7
  export const WordComponent = React.memo(function Word({
7
8
  word,
@@ -10,8 +11,9 @@ export const WordComponent = React.memo(function Word({
10
11
  isCorrectedGap,
11
12
  isUncorrectedGap,
12
13
  isCurrentlyPlaying,
13
- padding = '2px 4px',
14
+ padding = '1px 3px',
14
15
  onClick,
16
+ correction
15
17
  }: WordProps) {
16
18
  if (/^\s+$/.test(word)) {
17
19
  return word
@@ -29,15 +31,20 @@ export const WordComponent = React.memo(function Word({
29
31
  ? COLORS.uncorrectedGap
30
32
  : 'transparent'
31
33
 
32
- return (
34
+ const wordElement = (
33
35
  <HighlightedWord
34
36
  shouldFlash={shouldFlash}
35
37
  style={{
36
38
  backgroundColor,
37
39
  padding,
38
40
  cursor: 'pointer',
39
- borderRadius: '3px',
41
+ borderRadius: '2px',
40
42
  color: isCurrentlyPlaying ? '#ffffff' : 'inherit',
43
+ textDecoration: correction ? 'underline dotted' : 'none',
44
+ textDecorationColor: correction ? '#666' : 'inherit',
45
+ textUnderlineOffset: '2px',
46
+ fontSize: '0.85rem',
47
+ lineHeight: 1.2
41
48
  }}
42
49
  sx={{
43
50
  '&:hover': {
@@ -49,4 +56,22 @@ export const WordComponent = React.memo(function Word({
49
56
  {word}
50
57
  </HighlightedWord>
51
58
  )
59
+
60
+ if (correction) {
61
+ const tooltipContent = (
62
+ <>
63
+ <strong>Original:</strong> "{correction.originalWord}"<br />
64
+ <strong>Corrected by:</strong> {correction.handler}<br />
65
+ <strong>Confidence:</strong> {(correction.confidence * 100).toFixed(0)}%
66
+ </>
67
+ )
68
+
69
+ return (
70
+ <Tooltip title={tooltipContent} arrow placement="top">
71
+ {wordElement}
72
+ </Tooltip>
73
+ )
74
+ }
75
+
76
+ return wordElement
52
77
  })