Fix Smart Speed playback time contract

This commit is contained in:
Jonathan Baldie 2026-05-02 00:25:37 +01:00
parent 5c747a7f8f
commit 97c5d6341e
9 changed files with 205 additions and 17 deletions

View file

@ -159,8 +159,7 @@ export default {
return this.streamLibraryItem?.libraryId || null
},
totalDurationPretty() {
// Adjusted by playback rate
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
return this.$secondsToTimestamp(this.totalDuration)
},
podcastAuthor() {
if (!this.isPodcast) return null

View file

@ -20,7 +20,7 @@
<div class="flex px-4 py-2 items-center text-center border-b border-white/10 text-white/80">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
<div class="grow px-2">

View file

@ -2,13 +2,13 @@
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
<p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }}
</p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
<span class="grow" />
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div>
@ -43,15 +43,11 @@ export default {
this.$emit('input', val)
}
},
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() {
return this.currentChapter?.id || null
},
currentChapterStart() {
return (this.currentChapter?.start || 0) / this._playbackRate
return this.currentChapter?.start || 0
}
},
methods: {

View file

@ -189,7 +189,7 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
var hoverText = this.$secondsToTimestamp(progressTime)
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) {

View file

@ -132,9 +132,9 @@ export default {
timeRemaining() {
if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start
return (this.currentChapterDuration - currChapTime) / this.playbackRate
return this.currentChapterDuration - currChapTime
}
return (this.duration - this.currentTime) / this.playbackRate
return this.duration - this.currentTime
},
timeRemainingPretty() {
if (this.timeRemaining < 0) {
@ -309,7 +309,7 @@ export default {
return
}
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
ts.innerText = this.$secondsToTimestamp(time)
},
setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)

View file

@ -0,0 +1,110 @@
import LocalAudioPlayer from '../../../players/LocalAudioPlayer'
describe('LocalAudioPlayer', () => {
let mockPort;
let mockAudioContext;
let mockAudioWorkletNode;
beforeEach(() => {
// Mock for AudioWorkletNode message port
mockPort = {
onmessage: null,
postMessage: cy.stub()
};
// Mock AudioWorkletNode
mockAudioWorkletNode = {
port: mockPort,
connect: cy.stub(),
disconnect: cy.stub()
};
// Mock AudioContext
mockAudioContext = {
audioWorklet: {
addModule: cy.stub().resolves()
},
createMediaElementSource: cy.stub().returns({
connect: cy.stub(),
disconnect: cy.stub()
}),
destination: {},
state: 'running',
currentTime: 10
};
// Make AudioWorkletNode available globally so `new AudioWorkletNode` works
if (!window.AudioWorkletNode) {
window.AudioWorkletNode = function() { return mockAudioWorkletNode; };
} else {
cy.stub(window, 'AudioWorkletNode').returns(mockAudioWorkletNode);
}
if (!window.AudioContext) {
window.AudioContext = function() { return mockAudioContext; };
} else {
cy.stub(window, 'AudioContext').returns(mockAudioContext);
}
if (window.webkitAudioContext) {
cy.stub(window, 'webkitAudioContext').returns(mockAudioContext);
}
});
it('increases playbackRate during silence', () => {
const localPlayer = new LocalAudioPlayer({});
// Default playback rate should be 1
expect(localPlayer.player.playbackRate).to.equal(1);
cy.wrap(localPlayer).should('have.property', 'usingWebAudio', true).then(() => {
// Enable smart speed (this should trigger initSilenceDetector)
return localPlayer.setSmartSpeed(true);
}).then(() => {
expect(localPlayer.enableSmartSpeed).to.be.true;
expect(mockAudioContext.audioWorklet.addModule).to.have.been.calledWith('/client/players/smart-speed/SilenceDetectorProcessor.js');
expect(localPlayer.silenceDetectorNode).to.equal(mockAudioWorkletNode);
// Simulate silence start
mockPort.onmessage({
data: {
type: 'silence-start',
time: 5000 // 5 seconds
}
});
// The smartSpeedRatio is 2.0 by default, so playbackRate should be 2.0
expect(localPlayer.player.playbackRate).to.equal(2.0);
// Simulate silence end
mockPort.onmessage({
data: {
type: 'silence-end',
time: 8000 // 8 seconds
}
});
// Should return to default 1.0
expect(localPlayer.player.playbackRate).to.equal(1.0);
});
});
it('maps currentTime, duration, and seek through the same Smart Speed wall-clock contract', () => {
const localPlayer = new LocalAudioPlayer({});
localPlayer.audioTracks = [{ startOffset: 0, duration: 12 }];
localPlayer.currentTrackIndex = 0;
localPlayer.enableSmartSpeed = true;
localPlayer.smartSpeedRatio = 2.0;
localPlayer.silenceMap.addRegion(2000, 6000);
localPlayer.updateSmartSpeedRegions();
localPlayer.player.currentTime = 8;
expect(localPlayer.getCurrentTime()).to.equal(6);
expect(localPlayer.getDuration()).to.equal(10);
localPlayer.seek(6, false);
expect(localPlayer.player.currentTime).to.equal(8);
});
});

View file

@ -399,14 +399,21 @@ export default class LocalAudioPlayer extends EventEmitter {
getCurrentTime() {
var currentTrackOffset = this.currentTrack.startOffset || 0
if (!this.player) return 0
if (this.enableSmartSpeed) {
return this.timeMapper.audioToWallClock((currentTrackOffset + this.player.currentTime) * 1000) / 1000
}
return currentTrackOffset + this.player.currentTime
}
getDuration() {
if (!this.audioTracks.length) return 0
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
return lastTrack.startOffset + lastTrack.duration
const duration = lastTrack.startOffset + lastTrack.duration
if (this.enableSmartSpeed) {
return this.timeMapper.audioToWallClock(duration * 1000) / 1000
}
return duration
}
setPlaybackRate(playbackRate) {
@ -435,6 +442,10 @@ export default class LocalAudioPlayer extends EventEmitter {
var mappedTime = time
if (this.enableSmartSpeed) {
mappedTime = this.timeMapper.wallClockToAudio(time * 1000) / 1000
}
if (this.silenceDetectorNode) {
this.silenceDetectorNode.port.postMessage({ type: 'reset' })
this._silenceStartTime = null