From 8ad410d8a523d9344665225eed53c139cd6cf364 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 4 Mar 2026 15:07:43 +0100 Subject: [PATCH] feat: fix timestamps --- src/components/Slider.js | 1 - src/screens/Playback/RecordPlayback.js | 65 +++++++++-- src/screens/Playback/RecordPlayback.web.js | 6 +- src/screens/Playback/RecordedPlayback.js | 7 +- src/screens/Studio/SongReady.js | 58 ++++------ src/utils/trimLeadingDuplicateSection.js | 125 +++++++++++++++++++++ 6 files changed, 212 insertions(+), 50 deletions(-) create mode 100644 src/utils/trimLeadingDuplicateSection.js diff --git a/src/components/Slider.js b/src/components/Slider.js index d5cfa58..65d298e 100644 --- a/src/components/Slider.js +++ b/src/components/Slider.js @@ -77,7 +77,6 @@ export default ({ return { fillWidth, handleLeft } }, [layoutWidth, sliderValue]) - console.log("using react native slider ") return ( diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 08ff9e5..a0fda73 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -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 diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index 1355477..d1fe356 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -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), diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index 3aa32a9..4279002 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -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() diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index a3e2cf6..776d85e 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -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?.() + 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} /> diff --git a/src/utils/trimLeadingDuplicateSection.js b/src/utils/trimLeadingDuplicateSection.js new file mode 100644 index 0000000..b7f4335 --- /dev/null +++ b/src/utils/trimLeadingDuplicateSection.js @@ -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