lyrics-transcriber 0.43.1__py3-none-any.whl → 0.45.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 (51) 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-ZCT0s9MG.js} +10174 -6197
  8. lyrics_transcriber/frontend/dist/assets/index-ZCT0s9MG.js.map +1 -0
  9. lyrics_transcriber/frontend/dist/index.html +1 -1
  10. lyrics_transcriber/frontend/src/App.tsx +5 -5
  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/EditActionBar.tsx +68 -0
  16. lyrics_transcriber/frontend/src/components/EditModal.tsx +467 -399
  17. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +373 -0
  18. lyrics_transcriber/frontend/src/components/EditWordList.tsx +308 -0
  19. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  20. lyrics_transcriber/frontend/src/components/Header.tsx +141 -101
  21. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +569 -107
  22. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +22 -13
  23. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +1 -0
  24. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +29 -12
  25. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +21 -4
  26. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +29 -15
  27. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +36 -18
  28. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  29. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +89 -41
  30. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +9 -2
  31. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +27 -3
  32. lyrics_transcriber/frontend/src/components/shared/types.ts +17 -2
  33. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +90 -19
  34. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +192 -0
  35. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +267 -0
  36. lyrics_transcriber/frontend/src/main.tsx +7 -1
  37. lyrics_transcriber/frontend/src/theme.ts +177 -0
  38. lyrics_transcriber/frontend/src/types.ts +1 -1
  39. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  40. lyrics_transcriber/lyrics/base_lyrics_provider.py +2 -2
  41. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  42. lyrics_transcriber/output/generator.py +40 -12
  43. lyrics_transcriber/review/server.py +238 -8
  44. {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/METADATA +3 -2
  45. {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/RECORD +48 -40
  46. lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.js.map +0 -1
  47. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +0 -252
  48. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +0 -110
  49. {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/LICENSE +0 -0
  50. {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/WHEEL +0 -0
  51. {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/entry_points.txt +0 -0
@@ -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,21 @@ 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}
65
+ </>
66
+ )
67
+
68
+ return (
69
+ <Tooltip title={tooltipContent} arrow placement="top">
70
+ {wordElement}
71
+ </Tooltip>
72
+ )
73
+ }
74
+
75
+ return wordElement
52
76
  })
@@ -58,6 +58,11 @@ export interface WordProps {
58
58
  isCurrentlyPlaying?: boolean
59
59
  padding?: string
60
60
  onClick?: () => void
61
+ correction?: {
62
+ originalWord: string
63
+ handler: string
64
+ confidence: number
65
+ } | null
61
66
  }
62
67
 
63
68
  // Text segment props
@@ -100,11 +105,21 @@ export interface ReferenceViewProps extends BaseViewProps {
100
105
  // Update HighlightedTextProps to include linePositions
101
106
  export interface HighlightedTextProps extends BaseViewProps {
102
107
  text?: string
103
- wordPositions?: TranscriptionWordPosition[]
108
+ segments?: LyricsSegment[]
109
+ wordPositions: TranscriptionWordPosition[] | ReferenceWordPosition[]
104
110
  anchors: AnchorSequence[]
105
- gaps: GapSequence[]
111
+ highlightInfo: HighlightInfo | null
112
+ mode: InteractionMode
113
+ onElementClick: (content: ModalContent) => void
114
+ onWordClick?: (info: WordClickInfo) => void
115
+ flashingType: FlashType
106
116
  isReference?: boolean
107
117
  currentSource?: string
108
118
  preserveSegments?: boolean
109
119
  linePositions?: LinePosition[]
120
+ currentTime?: number
121
+ referenceCorrections?: Map<string, string>
122
+ gaps?: GapSequence[]
123
+ flashingHandler?: string | null
124
+ corrections?: WordCorrection[]
110
125
  }
@@ -1,10 +1,11 @@
1
1
  // Add a global ref for the modal handler
2
2
  let currentModalHandler: ((e: KeyboardEvent) => void) | undefined
3
3
  let isModalOpen = false
4
+ const debugLog = false
4
5
 
5
6
  type KeyboardState = {
6
7
  setIsShiftPressed: (value: boolean) => void
7
- setIsCtrlPressed: (value: boolean) => void
8
+ setIsCtrlPressed?: (value: boolean) => void
8
9
  modalHandler?: {
9
10
  isOpen: boolean
10
11
  onSpacebar?: (e: KeyboardEvent) => void
@@ -13,17 +14,45 @@ type KeyboardState = {
13
14
 
14
15
  // Add functions to update the modal handler state
15
16
  export const setModalHandler = (handler: ((e: KeyboardEvent) => void) | undefined, open: boolean) => {
17
+ if (debugLog) {
18
+ console.log('setModalHandler called', {
19
+ hasHandler: !!handler,
20
+ open,
21
+ previousState: {
22
+ hadHandler: !!currentModalHandler,
23
+ wasOpen: isModalOpen
24
+ }
25
+ })
26
+ }
27
+
16
28
  currentModalHandler = handler
17
29
  isModalOpen = open
18
30
  }
19
31
 
20
32
  export const setupKeyboardHandlers = (state: KeyboardState) => {
21
33
  const handlerId = Math.random().toString(36).substr(2, 9)
22
- console.log(`Setting up keyboard handlers [${handlerId}]`)
34
+ if (debugLog) {
35
+ console.log(`Setting up keyboard handlers [${handlerId}]`)
36
+ }
23
37
 
24
38
  const handleKeyDown = (e: KeyboardEvent) => {
39
+ if (debugLog) {
40
+ console.log(`Keyboard event captured [${handlerId}]`, {
41
+ key: e.key,
42
+ code: e.code,
43
+ type: e.type,
44
+ target: e.target,
45
+ currentTarget: e.currentTarget,
46
+ eventPhase: e.eventPhase,
47
+ isModalOpen,
48
+ hasModalHandler: !!currentModalHandler
49
+ })
50
+ }
51
+
25
52
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
26
- console.log(`[${handlerId}] Ignoring keydown in input/textarea`)
53
+ if (debugLog) {
54
+ console.log(`[${handlerId}] Ignoring keydown in input/textarea`)
55
+ }
27
56
  return
28
57
  }
29
58
 
@@ -31,37 +60,79 @@ export const setupKeyboardHandlers = (state: KeyboardState) => {
31
60
  state.setIsShiftPressed(true)
32
61
  document.body.style.userSelect = 'none'
33
62
  } else if (e.key === 'Meta') {
34
- state.setIsCtrlPressed(true)
63
+ state.setIsCtrlPressed?.(true)
35
64
  } else if (e.key === ' ' || e.code === 'Space') {
36
- console.log(`[${handlerId}] Spacebar pressed:`, {
37
- modalOpen: isModalOpen,
38
- hasModalHandler: !!currentModalHandler,
39
- hasGlobalToggle: !!window.toggleAudioPlayback
40
- })
41
-
65
+ if (debugLog) {
66
+ console.log('Keyboard handler - Spacebar pressed down', {
67
+ modalOpen: isModalOpen,
68
+ hasModalHandler: !!currentModalHandler,
69
+ hasGlobalToggle: !!window.toggleAudioPlayback,
70
+ target: e.target,
71
+ eventPhase: e.eventPhase,
72
+ handlerFunction: currentModalHandler?.toString().slice(0, 100)
73
+ })
74
+ }
75
+
42
76
  e.preventDefault()
43
-
44
- // If modal is open and has a handler, use that
77
+
45
78
  if (isModalOpen && currentModalHandler) {
46
- console.log(`[${handlerId}] Using modal spacebar handler`)
79
+ if (debugLog) {
80
+ console.log('Keyboard handler - Delegating to modal handler')
81
+ }
47
82
  currentModalHandler(e)
48
- }
49
- // Otherwise use global audio control
50
- else if (window.toggleAudioPlayback && !isModalOpen) {
51
- console.log(`[${handlerId}] Using global audio toggle`)
83
+ } else if (window.toggleAudioPlayback && !isModalOpen) {
84
+ if (debugLog) {
85
+ console.log('Keyboard handler - Using global audio toggle')
86
+ }
52
87
  window.toggleAudioPlayback()
53
88
  }
54
89
  }
55
90
  }
56
91
 
57
92
  const handleKeyUp = (e: KeyboardEvent) => {
93
+ if (debugLog) {
94
+ console.log(`Keyboard up event captured [${handlerId}]`, {
95
+ key: e.key,
96
+ code: e.code,
97
+ type: e.type,
98
+ target: e.target,
99
+ eventPhase: e.eventPhase,
100
+ isModalOpen,
101
+ hasModalHandler: !!currentModalHandler
102
+ })
103
+ }
104
+
58
105
  if (e.key === 'Shift') {
59
106
  state.setIsShiftPressed(false)
60
107
  document.body.style.userSelect = ''
61
108
  } else if (e.key === 'Meta') {
62
- state.setIsCtrlPressed(false)
109
+ state.setIsCtrlPressed?.(false)
110
+ } else if (e.key === ' ' || e.code === 'Space') {
111
+ if (debugLog) {
112
+ console.log('Keyboard handler - Spacebar released', {
113
+ modalOpen: isModalOpen,
114
+ hasModalHandler: !!currentModalHandler,
115
+ target: e.target,
116
+ eventPhase: e.eventPhase
117
+ })
118
+ }
119
+
120
+ e.preventDefault()
121
+
122
+ if (isModalOpen && currentModalHandler) {
123
+ if (debugLog) {
124
+ console.log('Keyboard handler - Delegating keyup to modal handler')
125
+ }
126
+ currentModalHandler(e)
127
+ }
63
128
  }
64
129
  }
65
130
 
66
131
  return { handleKeyDown, handleKeyUp }
67
- }
132
+ }
133
+
134
+ // Export these for external use
135
+ export const getModalState = () => ({
136
+ currentModalHandler,
137
+ isModalOpen
138
+ })
@@ -117,5 +117,197 @@ export const updateSegment = (
117
117
 
118
118
  newData.corrected_segments[segmentIndex] = updatedSegment
119
119
 
120
+ return newData
121
+ }
122
+
123
+ export function mergeSegment(data: CorrectionData, segmentIndex: number, mergeWithNext: boolean): CorrectionData {
124
+ const segments = [...data.corrected_segments]
125
+ const targetIndex = mergeWithNext ? segmentIndex + 1 : segmentIndex - 1
126
+
127
+ // Check if target segment exists
128
+ if (targetIndex < 0 || targetIndex >= segments.length) {
129
+ return data
130
+ }
131
+
132
+ const baseSegment = segments[segmentIndex]
133
+ const targetSegment = segments[targetIndex]
134
+
135
+ // Create merged segment
136
+ const mergedSegment: LyricsSegment = {
137
+ id: nanoid(),
138
+ words: mergeWithNext
139
+ ? [...baseSegment.words, ...targetSegment.words]
140
+ : [...targetSegment.words, ...baseSegment.words],
141
+ text: mergeWithNext
142
+ ? `${baseSegment.text} ${targetSegment.text}`
143
+ : `${targetSegment.text} ${baseSegment.text}`,
144
+ start_time: Math.min(
145
+ baseSegment.start_time ?? Infinity,
146
+ targetSegment.start_time ?? Infinity
147
+ ),
148
+ end_time: Math.max(
149
+ baseSegment.end_time ?? -Infinity,
150
+ targetSegment.end_time ?? -Infinity
151
+ )
152
+ }
153
+
154
+ // Replace the two segments with the merged one
155
+ const minIndex = Math.min(segmentIndex, targetIndex)
156
+ segments.splice(minIndex, 2, mergedSegment)
157
+
158
+ return {
159
+ ...data,
160
+ corrected_segments: segments
161
+ }
162
+ }
163
+
164
+ export function findAndReplace(
165
+ data: CorrectionData,
166
+ findText: string,
167
+ replaceText: string,
168
+ options: { caseSensitive: boolean, useRegex: boolean, fullTextMode?: boolean } = {
169
+ caseSensitive: false,
170
+ useRegex: false,
171
+ fullTextMode: false
172
+ }
173
+ ): CorrectionData {
174
+ const newData = { ...data }
175
+
176
+ // If full text mode is enabled, perform replacements across word boundaries
177
+ if (options.fullTextMode) {
178
+ newData.corrected_segments = data.corrected_segments.map(segment => {
179
+ // Create a pattern for the full segment text
180
+ let pattern: RegExp
181
+
182
+ if (options.useRegex) {
183
+ pattern = new RegExp(findText, options.caseSensitive ? 'g' : 'gi')
184
+ } else {
185
+ const escapedFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
186
+ pattern = new RegExp(escapedFindText, options.caseSensitive ? 'g' : 'gi')
187
+ }
188
+
189
+ // Get the full segment text
190
+ const segmentText = segment.text
191
+
192
+ // If no matches, return the segment unchanged
193
+ if (!pattern.test(segmentText)) {
194
+ return segment
195
+ }
196
+
197
+ // Reset pattern for replacement
198
+ pattern.lastIndex = 0
199
+
200
+ // Replace in the full segment text
201
+ const newSegmentText = segmentText.replace(pattern, replaceText)
202
+
203
+ // Split the new text into words
204
+ const newWordTexts = newSegmentText.trim().split(/\s+/).filter(text => text.length > 0)
205
+
206
+ // Create new word objects
207
+ // We'll try to preserve original word IDs and timing info where possible
208
+ const newWords = []
209
+
210
+ // If we have the same number of words, we can preserve IDs and timing
211
+ if (newWordTexts.length === segment.words.length) {
212
+ for (let i = 0; i < newWordTexts.length; i++) {
213
+ newWords.push({
214
+ ...segment.words[i],
215
+ text: newWordTexts[i]
216
+ })
217
+ }
218
+ }
219
+ // If we have fewer words than before, some words were removed
220
+ else if (newWordTexts.length < segment.words.length) {
221
+ // Try to map new words to old words
222
+ let oldWordIndex = 0
223
+ for (let i = 0; i < newWordTexts.length; i++) {
224
+ // Find the next non-empty old word
225
+ while (oldWordIndex < segment.words.length &&
226
+ segment.words[oldWordIndex].text.trim() === '') {
227
+ oldWordIndex++
228
+ }
229
+
230
+ if (oldWordIndex < segment.words.length) {
231
+ newWords.push({
232
+ ...segment.words[oldWordIndex],
233
+ text: newWordTexts[i]
234
+ })
235
+ oldWordIndex++
236
+ } else {
237
+ // If we run out of old words, create new ones
238
+ newWords.push({
239
+ id: nanoid(),
240
+ text: newWordTexts[i],
241
+ start_time: null,
242
+ end_time: null
243
+ })
244
+ }
245
+ }
246
+ }
247
+ // If we have more words than before, some words were added
248
+ else {
249
+ // Try to preserve original words where possible
250
+ for (let i = 0; i < newWordTexts.length; i++) {
251
+ if (i < segment.words.length) {
252
+ newWords.push({
253
+ ...segment.words[i],
254
+ text: newWordTexts[i]
255
+ })
256
+ } else {
257
+ // For new words, create new IDs
258
+ newWords.push({
259
+ id: nanoid(),
260
+ text: newWordTexts[i],
261
+ start_time: null,
262
+ end_time: null
263
+ })
264
+ }
265
+ }
266
+ }
267
+
268
+ return {
269
+ ...segment,
270
+ words: newWords,
271
+ text: newSegmentText
272
+ }
273
+ })
274
+ }
275
+ // Word-level replacement (original implementation)
276
+ else {
277
+ newData.corrected_segments = data.corrected_segments.map(segment => {
278
+ // Replace in each word
279
+ let newWords = segment.words.map(word => {
280
+ let pattern: RegExp
281
+
282
+ if (options.useRegex) {
283
+ // Create regex with or without case sensitivity
284
+ pattern = new RegExp(findText, options.caseSensitive ? 'g' : 'gi')
285
+ } else {
286
+ // Escape special regex characters for literal search
287
+ const escapedFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
288
+ pattern = new RegExp(escapedFindText, options.caseSensitive ? 'g' : 'gi')
289
+ }
290
+
291
+ return {
292
+ ...word,
293
+ text: word.text.replace(pattern, replaceText)
294
+ }
295
+ });
296
+
297
+ // Filter out words that have become empty
298
+ newWords = newWords.filter(word => word.text.trim() !== '');
299
+
300
+ // Update segment text
301
+ return {
302
+ ...segment,
303
+ words: newWords,
304
+ text: newWords.map(w => w.text).join(' ')
305
+ }
306
+ });
307
+ }
308
+
309
+ // Filter out segments that have no words left
310
+ newData.corrected_segments = newData.corrected_segments.filter(segment => segment.words.length > 0);
311
+
120
312
  return newData
121
313
  }