feat: fix timestamps
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user