feat: fix timestamps

This commit is contained in:
2026-03-04 15:07:43 +01:00
parent d4fd96ce03
commit 8ad410d8a5
6 changed files with 212 additions and 50 deletions
-1
View File
@@ -77,7 +77,6 @@ export default ({
return { fillWidth, handleLeft }
}, [layoutWidth, sliderValue])
console.log("using react native slider ")
return (
<View>
<View style={styles.trackArea} onLayout={handleLayout}>
+56 -9
View File
@@ -18,6 +18,7 @@ import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
@@ -64,6 +65,9 @@ const RecordPlayback = ({ route }) => {
const countdownActiveRef = useRef(false) // évite le déclenchement avant 1er tick
const playbackStartedRef = useRef(false) // devient vrai lorsque l'audio progresse réellement
const playbackStartedAtRef = useRef(0)
const playbackProbeRef = useRef({ lastPos: 0, lastAt: 0 })
const playbackStartRequestAtRef = useRef(0)
const playbackStartWarnedRef = useRef(false)
const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 })
const originalLoopingValueRef = useRef({ hasValue: false, value: false })
const latestLoopingValueRef = useRef(isLooping ?? false)
@@ -132,9 +136,14 @@ const RecordPlayback = ({ route }) => {
const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[musicIndex]
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []
return arr.map((w) => ({
const rawAlignedWords = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []
const trimmedAlignedWords = trimLeadingDuplicateSection(rawAlignedWords)
if (trimmedAlignedWords.length !== rawAlignedWords.length) {
log('Trimmed duplicated leading lyrics section', {
removedWords: rawAlignedWords.length - trimmedAlignedWords.length,
})
}
return trimmedAlignedWords.map((w) => ({
word: String(w?.word ?? ''),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
@@ -194,6 +203,10 @@ const RecordPlayback = ({ route }) => {
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
playbackStartedRef.current = false
playbackStartedAtRef.current = 0
playbackProbeRef.current = { lastPos: 0, lastAt: 0 }
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
log('Song URL changed, reset counters', { songUrl })
}, [songUrl])
@@ -202,18 +215,43 @@ const RecordPlayback = ({ route }) => {
if (!player) return
const id = setInterval(() => {
try {
const now = Date.now()
const dur = (player?.duration || 0) * 1000
const pos = (player?.currentTime || 0) * 1000
if (!playbackStartedRef.current && (player?.playing || pos > 0)) {
const lastProbe = playbackProbeRef.current
const advancing = pos > (lastProbe?.lastPos || 0) + 30
const playbackHasAdvanced = pos >= 80 || advancing
playbackProbeRef.current = { lastPos: pos, lastAt: now }
if (
playbackStartRequestAtRef.current &&
!playbackStartedRef.current &&
!playbackStartWarnedRef.current &&
now - playbackStartRequestAtRef.current > 1500
) {
playbackStartWarnedRef.current = true
log('Playback not started yet', {
playing: player?.playing,
pos,
dur,
})
}
if (!playbackStartedRef.current && playbackHasAdvanced) {
playbackStartedRef.current = true
const now = Date.now()
const estimatedStartAt = pos > 0 ? now - pos : now
playbackStartedAtRef.current = estimatedStartAt
log('Playback detected', { pos, dur, startedAt: estimatedStartAt })
log('Playback detected', {
pos,
dur,
startedAt: estimatedStartAt,
reason: advancing ? 'advancing' : 'position',
})
}
setProgressInfo((prev) => {
if (prev.pos === pos && prev.dur === dur) return prev
return { pos, dur }
const effectivePos = playbackStartedRef.current ? pos : 0
if (prev.pos === effectivePos && prev.dur === dur) return prev
return { pos: effectivePos, dur }
})
if (dur <= 0) {
if (progressLogRef.current.lastDur !== 0) {
@@ -317,6 +355,10 @@ const RecordPlayback = ({ route }) => {
incrementDoneRef.current = false
playbackStartedRef.current = false
playbackStartedAtRef.current = 0
playbackProbeRef.current = { lastPos: 0, lastAt: 0 }
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
log('Session reset')
@@ -474,6 +516,10 @@ const RecordPlayback = ({ route }) => {
await cameraRef.current?.resumePreview?.()
} catch (_) { }
playbackStartedRef.current = false
playbackProbeRef.current = { lastPos: 0, lastAt: 0 }
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
log('startRecordingWithMusic', {
hasPlayer: !!player,
@@ -556,6 +602,7 @@ const RecordPlayback = ({ route }) => {
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS))
try {
playbackStartRequestAtRef.current = Date.now()
await player.play?.()
log('player.play invoked')
} catch (error) {
@@ -605,7 +652,7 @@ const RecordPlayback = ({ route }) => {
}
const duration = (player?.duration || 0) * 1000
const currentTime = (player?.currentTime || 0) * 1000
if (!playbackStartedRef.current && (player?.playing || currentTime > 0)) {
if (!playbackStartedRef.current && player?.playing) {
playbackStartedRef.current = true
}
const nearEnd = duration > 0 && currentTime >= duration - 600
+4 -2
View File
@@ -20,6 +20,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { background } from '../../assets'
import RestartSpinnerIcon from '../../assets/UI/RestartSpinnerIcon'
import { registerBlobUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
const TIME_BEFORE_INCREMENT_MS = 20000
const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10
@@ -123,8 +124,9 @@ const RecordPlayback = ({ route }) => {
// Lyrics
const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[songIndex]
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []
return arr.map((w) => ({
const rawAlignedWords = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []
const trimmedAlignedWords = trimLeadingDuplicateSection(rawAlignedWords)
return trimmedAlignedWords.map((w) => ({
word: String(w?.word ?? ''),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
+6 -1
View File
@@ -16,6 +16,7 @@ import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
const RecordedPlayback = ({ route }) => {
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
const songUrl = project?.songUrl || null
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
@@ -127,7 +128,11 @@ const RecordedPlayback = ({ route }) => {
const resumeAfterSeek = useCallback(async () => {
try {
if (audioPlayer) await audioPlayer.play?.()
if (audioPlayer?.resume) {
await audioPlayer.resume?.()
} else if (audioPlayer) {
await audioPlayer.play?.()
}
} catch (e) { }
try {
if (videoPlayer) videoPlayer.play()
+20 -36
View File
@@ -354,7 +354,6 @@ const SongOptionCard = ({
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 })
const [isPlaying, setIsPlaying] = useState(false)
const shouldResumeAfterSeekRef = useRef(false)
useEffect(() => {
registerPlayer(index, player)
@@ -402,27 +401,6 @@ const SongOptionCard = ({
return () => clearInterval(id)
}, [player])
const hasCapturedSeekStateRef = useRef(false)
const resumePlaybackIfNeeded = useCallback(async () => {
if (!player) return
const shouldResume = shouldResumeAfterSeekRef.current
shouldResumeAfterSeekRef.current = false
hasCapturedSeekStateRef.current = false // Reset capture flag
if (!shouldResume) return
try {
if (player.resume) {
await player.resume?.()
} else {
await player.play?.()
}
} catch (error) {
console.log('SongReady resume after seek', error?.message)
}
}, [player])
const handleSeek = useCallback(
async (targetMs) => {
if (!player || !progressInfo?.dur) {
@@ -443,26 +421,31 @@ const SongOptionCard = ({
[player, progressInfo?.dur]
)
const handleSeekStart = useCallback(async () => {
const handleSeekStart = useCallback(() => {
onSelect(index)
}, [index, onSelect])
const handlePause = useCallback(async () => {
if (!player) return
const isCurrentlyPlaying = !!player.playing || isPlaying
// Only capture state if we haven't already for this drag interaction
if (!hasCapturedSeekStateRef.current) {
hasCapturedSeekStateRef.current = true
shouldResumeAfterSeekRef.current = isCurrentlyPlaying
}
try {
if (isCurrentlyPlaying) {
await player.pause?.()
} catch (error) {
console.log('SongReady pause on seek', error?.message)
}
}, [player])
const handlePlay = useCallback(async () => {
if (!player) return
try {
if (player.resume) {
await player.resume?.()
} else {
await player.play?.()
}
} catch (error) {
console.log('SongReady pause on seek start', error?.message)
console.log('SongReady resume on seek', error?.message)
}
}, [index, isPlaying, onSelect, player])
}, [player])
const handleToggle = () => {
onSelect(index)
@@ -521,7 +504,8 @@ const SongOptionCard = ({
isPlaying={isPlaying}
onSeekStart={handleSeekStart}
onSeek={handleSeek}
onSeekEnd={resumePlaybackIfNeeded}
onPause={handlePause}
onPlay={handlePlay}
disabled={!url}
/>
</View>
+125
View File
@@ -0,0 +1,125 @@
const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i
const TOKEN_CLEANUP_REGEX = /[.,!?;:()[\]{}"'`]+/g
const MAX_FIRST_SECTION_START_S = 3
const MIN_DUPLICATE_SECTION_START_S = 8
const MIN_DUPLICATE_GAP_S = 4
const MIN_DUPLICATE_PREFIX_TOKENS = 12
const MIN_DUPLICATE_PREFIX_RATIO = 0.9
const toSeconds = (value) => {
const n = Number(value)
return Number.isFinite(n) && n >= 0 ? n : 0
}
const normalizeTag = (value = '') =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
const normalizeToken = (value = '') =>
String(value || '')
.replace(/\n/g, ' ')
.replace(TOKEN_CLEANUP_REGEX, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
const tokenizeWord = (value = '') => {
const normalized = normalizeToken(value)
return normalized ? normalized.split(' ') : []
}
const isVerseLikeTag = (tag = '') => {
const normalized = normalizeTag(tag)
return normalized.includes('verse') || normalized.includes('couplet')
}
const buildSegments = (alignedWords = []) => {
const segments = []
let current = null
const flush = () => {
if (current && current.tokens.length > 0) {
segments.push(current)
}
current = null
}
for (let index = 0; index < alignedWords.length; index++) {
const entry = alignedWords[index] || {}
const rawWord = String(entry.word || '')
const tagMatch = rawWord.match(SECTION_TAG_LEADING_REGEX)
if (tagMatch) {
flush()
current = {
tag: normalizeTag(tagMatch[1]),
startIdx: index,
endIdx: index,
startS: toSeconds(entry.startS),
tokens: [],
}
} else if (!current) {
current = {
tag: '',
startIdx: index,
endIdx: index,
startS: toSeconds(entry.startS),
tokens: [],
}
}
const noTagWord = tagMatch ? rawWord.replace(SECTION_TAG_LEADING_REGEX, '') : rawWord
const tokens = tokenizeWord(noTagWord)
if (tokens.length > 0) {
current.tokens.push(...tokens)
current.endIdx = index
} else if (current && current.tokens.length === 0) {
current.endIdx = index
}
}
flush()
return segments
}
const countCommonPrefixTokens = (tokensA = [], tokensB = []) => {
const max = Math.min(tokensA.length, tokensB.length)
let matched = 0
while (matched < max && tokensA[matched] === tokensB[matched]) {
matched += 1
}
return matched
}
export const trimLeadingDuplicateSection = (alignedWords = []) => {
if (!Array.isArray(alignedWords) || alignedWords.length === 0) return []
const segments = buildSegments(alignedWords)
if (segments.length < 2) return alignedWords
const first = segments[0]
const second = segments[1]
if (!isVerseLikeTag(first.tag) || !isVerseLikeTag(second.tag)) return alignedWords
if (first.tag !== second.tag) return alignedWords
const firstStartS = toSeconds(first.startS)
const secondStartS = toSeconds(second.startS)
if (firstStartS > MAX_FIRST_SECTION_START_S) return alignedWords
if (secondStartS < MIN_DUPLICATE_SECTION_START_S) return alignedWords
if (secondStartS - firstStartS < MIN_DUPLICATE_GAP_S) return alignedWords
const matchedPrefixTokens = countCommonPrefixTokens(first.tokens, second.tokens)
const minTokenCount = Math.min(first.tokens.length, second.tokens.length)
const matchedRatio = minTokenCount > 0 ? matchedPrefixTokens / minTokenCount : 0
if (matchedPrefixTokens < MIN_DUPLICATE_PREFIX_TOKENS) return alignedWords
if (matchedRatio < MIN_DUPLICATE_PREFIX_RATIO) return alignedWords
return [...alignedWords.slice(0, first.startIdx), ...alignedWords.slice(first.endIdx + 1)]
}
export default trimLeadingDuplicateSection