diff --git a/src/renderer/cypress/support/pbtHarness.tsx b/src/renderer/cypress/support/pbtHarness.tsx index 725d14045..b9584e89c 100644 --- a/src/renderer/cypress/support/pbtHarness.tsx +++ b/src/renderer/cypress/support/pbtHarness.tsx @@ -251,6 +251,11 @@ export function installPbtServer() { const take = serverState.takes.find((t) => t.remoteId === remoteId); // The real pull-after-upload is what makes the take visible; model it // (optionally late) so rowData-lag behaviour is reproducible. + // + // Timed from when the PUT *completes*, not when it starts: the audio is not + // stored until then. Adding it up front made putDelayMs lie - the take + // appeared in rowData while the upload was still in flight, so a test about + // what happens during an upload was really testing what happens after one. if (take) { const gen = serverState.generation; const add = () => { @@ -258,10 +263,9 @@ export function installPbtServer() { if (serverState.generation !== gen) return; serverState.addTakeToMemory?.(take); }; - if (serverState.rowDataLagMs > 0) { - serverState.pendingTimers.push( - setTimeout(add, serverState.rowDataLagMs) - ); + const storedAfterMs = serverState.putDelayMs + serverState.rowDataLagMs; + if (storedAfterMs > 0) { + serverState.pendingTimers.push(setTimeout(add, storedAfterMs)); } else add(); } req.reply({ statusCode: 200, body: '', delay: serverState.putDelayMs }); diff --git a/src/renderer/src/components/PassageDetail/PassageDetailGuidedPhraseRecord.tsx b/src/renderer/src/components/PassageDetail/PassageDetailGuidedPhraseRecord.tsx index bbcf9ad38..49aae73a2 100644 --- a/src/renderer/src/components/PassageDetail/PassageDetailGuidedPhraseRecord.tsx +++ b/src/renderer/src/components/PassageDetail/PassageDetailGuidedPhraseRecord.tsx @@ -248,6 +248,20 @@ export function PassageDetailGuidedPhraseRecord({ const pendingOvershootSwallowRef = useRef(false); /** Indices saved this session whose rowData may not have caught up yet (TT-7552). */ const optimisticCompletedRef = useRef>(new Set()); + /** + * The user discarded a take while its upload was still in flight. The upload + * still completes, so both the recorder state it reports and the mediafile it + * creates have to be undone - otherwise the take the user deleted comes back, + * and the segment counts as recorded with audio they rejected. + */ + const discardedDuringSaveRef = useRef(undefined); + /** + * A save has been requested and its outcome has not arrived yet. Tracked + * separately from savingRecording, which other paths clear early - by the time + * the user can click the delete icon it is already false, so it cannot answer + * "is an upload still in flight". + */ + const uploadInFlightRef = useRef(false); const currentIndexRef = useRef(0); const [heardIndices, setHeardIndices] = useState([]); const [currentClausePlayed, setCurrentClausePlayed] = useState(false); @@ -644,6 +658,7 @@ export function PassageDetailGuidedPhraseRecord({ if (canSave && !saveRejectedRef.current) { savingRecordingRef.current = true; setSavingRecording(true); + uploadInFlightRef.current = true; startSave(toolId); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -656,6 +671,7 @@ export function PassageDetailGuidedPhraseRecord({ setSaveRejected(false); savingRecordingRef.current = true; setSavingRecording(true); + uploadInFlightRef.current = true; startSave(toolId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [toolId]); @@ -1639,6 +1655,23 @@ export function PassageDetailGuidedPhraseRecord({ const afterUploadCb = useCallback( async (mediaId: string | undefined) => { + uploadInFlightRef.current = false; + // Deliberately does not clear the marker: the mediafile this upload + // created may not have arrived yet, and the effect below still has to + // remove it. Whichever of the two happens first, both must see it. + if (discardedDuringSaveRef.current === currentIndexRef.current) { + // Discarded while this upload was in flight. Leave the flag set: the + // mediafile it created has not reached rowData yet, and the effect below + // removes it once it does. + optimisticCompletedRef.current.delete(currentIndexRef.current); + savingRecordingRef.current = false; + setSavingRecording(false); + setPhase('recordReady'); + setResetMedia(true); + forceRefresh(); + applyColors(); + return; + } // Color green immediately; rowData/forceRefresh often lag the upload // (TT-7552). Only on a real upload though — a terminal failure still calls // us, with no mediaId, and painting that green tells the user their take @@ -1662,6 +1695,12 @@ export function PassageDetailGuidedPhraseRecord({ ); const handleClearRecording = useCallback(async () => { + // A save still in flight will finish and report a stored take. Remember that + // the user has discarded it so afterUploadCb, and the effect that watches + // for the mediafile arriving, can undo both halves. + if (uploadInFlightRef.current) { + discardedDuringSaveRef.current = currentIndexRef.current; + } // Deleting the take retires the failed save with it, so the message and the // latch must both go (TT-7583). saveRejectedRef.current = false; @@ -1694,6 +1733,37 @@ export function PassageDetailGuidedPhraseRecord({ setStepComplete, ]); + /** + * Remove the take an in-flight upload stored after the user had already + * discarded it. It cannot be removed in afterUploadCb: the mediafile has not + * reached rowData at that point, so there is nothing to address yet. Waiting + * for it to appear also means this works whether the upload finishes before or + * after the local sync. + */ + useEffect(() => { + const discardedUnit = discardedDuringSaveRef.current; + if (discardedUnit === undefined) return; + if (discardedUnit !== currentIndexRef.current) return; + const mediaId = recordingRow?.mediafile?.id; + if (!mediaId) return; + discardedDuringSaveRef.current = undefined; + void (async () => { + await memory.update((t) => + t.removeRecord({ type: 'mediafile', id: mediaId }) + ); + optimisticCompletedRef.current.delete(currentIndexRef.current); + if (stepComplete(currentstep)) { + await setStepComplete(currentstep, false); + } + forceRefresh(); + setPhase('recordReady'); + setResetMedia(true); + applyColors(); + })(); + // stepComplete reads psgCompleted internally; only the row matters here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recordingRow, memory, forceRefresh, applyColors, currentstep]); + const allowRecord = recordingPassStarted && currentClausePlayed && @@ -1731,6 +1801,13 @@ export function PassageDetailGuidedPhraseRecord({ return ( { if (active) { recordingActiveRef.current = true; + // A fresh take on this segment is wanted, so stop treating an + // arriving upload for it as the discarded one. + discardedDuringSaveRef.current = undefined; // A new take supersedes any earlier rejected save (TT-7583). saveRejectedRef.current = false; setSaveRejected(false); @@ -1869,6 +1949,7 @@ export function PassageDetailGuidedPhraseRecord({ setResetMedia={setResetMedia} setCanSave={setCanSave} onSaveRejected={() => { + uploadInFlightRef.current = false; saveRejectedRef.current = true; setSaveRejected(true); savingRecordingRef.current = false;