mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-12 20:31:36 +00:00
Merge e4e74c3b05 into 82aec5f60c
This commit is contained in:
commit
fd9e19ffae
23 changed files with 1249 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,18 @@
|
|||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="playbackRateIncrementDecrement" :label="$strings.LabelPlaybackRateIncrementDecrement" menuMaxHeight="250px" :items="playbackRateIncrementDecrementValues" @input="setPlaybackRateIncrementDecrementAmount" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isCasting" class="w-full h-px bg-white/10 my-6"></div>
|
||||
|
||||
<div v-if="!isCasting" class="flex items-center mb-4">
|
||||
<ui-toggle-switch v-model="enableSmartSpeed" @input="setEnableSmartSpeed" />
|
||||
<div class="pl-4">
|
||||
<span>{{ $strings.LabelEnableSmartSpeed || 'Enable Smart Speed' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isCasting" class="flex items-center mb-4" :class="{'opacity-50 pointer-events-none': !enableSmartSpeed}">
|
||||
<ui-select-input v-model="smartSpeedRatio" :label="$strings.LabelSmartSpeedRatio || 'Smart Speed Compression Ratio'" menuMaxHeight="250px" :items="smartSpeedRatioValues" @input="setSmartSpeedRatio" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
|
@ -40,7 +52,17 @@ export default {
|
|||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10,
|
||||
playbackRateIncrementDecrementValues: [0.1, 0.05],
|
||||
playbackRateIncrementDecrement: 0.1
|
||||
playbackRateIncrementDecrement: 0.1,
|
||||
enableSmartSpeed: false,
|
||||
smartSpeedRatio: 2.5,
|
||||
smartSpeedRatioValues: [
|
||||
{ text: '1.5x', value: 1.5 },
|
||||
{ text: '2.0x', value: 2.0 },
|
||||
{ text: '2.5x', value: 2.5 },
|
||||
{ text: '3.0x', value: 3.0 },
|
||||
{ text: '4.0x', value: 4.0 },
|
||||
{ text: '5.0x', value: 5.0 }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -51,6 +73,9 @@ export default {
|
|||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting || false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -69,11 +94,24 @@ export default {
|
|||
this.playbackRateIncrementDecrement = val
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val })
|
||||
},
|
||||
setEnableSmartSpeed() {
|
||||
this.$store.commit('user/SET_SMART_SPEED_ENABLED', this.enableSmartSpeed)
|
||||
},
|
||||
setSmartSpeedRatio(val) {
|
||||
this.smartSpeedRatio = val
|
||||
this.$store.commit('user/SET_SMART_SPEED_RATIO', val)
|
||||
},
|
||||
settingsUpdated() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')
|
||||
|
||||
const enableSmartSpeed = this.$store.getters['user/getUserSetting']('enableSmartSpeed')
|
||||
this.enableSmartSpeed = enableSmartSpeed !== null ? enableSmartSpeed : false
|
||||
|
||||
const smartSpeedRatio = this.$store.getters['user/getUserSetting']('smartSpeedRatio')
|
||||
this.smartSpeedRatio = smartSpeedRatio !== null ? smartSpeedRatio : 2.5
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
|
||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grow overflow-hidden px-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<!-- Smart Speed Indicator -->
|
||||
<div v-if="isSmartSpeedEnabled && !isCasting" class="absolute -top-6 right-0 text-xs text-yellow-400 flex items-center bg-black/50 px-2 py-0.5 rounded shadow-sm z-10 pointer-events-none">
|
||||
<span class="material-symbols text-sm mr-1">bolt</span>
|
||||
<span>Smart Speed Active</span>
|
||||
</div>
|
||||
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
|
|
@ -63,6 +69,12 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting || false
|
||||
},
|
||||
isSmartSpeedEnabled() {
|
||||
return this.$store.getters['user/getUserSetting']('enableSmartSpeed') || false
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
|
|
@ -177,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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -326,11 +326,22 @@ export default {
|
|||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
|
||||
const enableSmartSpeed = this.$store.getters['user/getUserSetting']('enableSmartSpeed')
|
||||
const smartSpeedRatio = this.$store.getters['user/getUserSetting']('smartSpeedRatio')
|
||||
if (this.playerHandler && this.playerHandler.isPlayingLocalItem) {
|
||||
this.playerHandler.setSmartSpeed(enableSmartSpeed || false, smartSpeedRatio || 2.5)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.setPlaybackRate(settings.playbackRate)
|
||||
}
|
||||
if (this.playerHandler && this.playerHandler.isPlayingLocalItem && (settings.enableSmartSpeed !== undefined || settings.smartSpeedRatio !== undefined)) {
|
||||
const enableSmartSpeed = settings.enableSmartSpeed !== undefined ? settings.enableSmartSpeed : this.$store.getters['user/getUserSetting']('enableSmartSpeed')
|
||||
const smartSpeedRatio = settings.smartSpeedRatio !== undefined ? settings.smartSpeedRatio : this.$store.getters['user/getUserSetting']('smartSpeedRatio')
|
||||
this.playerHandler.setSmartSpeed(enableSmartSpeed || false, smartSpeedRatio || 2.5)
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.isFullscreen) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import BookmarkItem from '@/components/modals/bookmarks/BookmarkItem.vue'
|
||||
|
||||
describe('BookmarkItem', () => {
|
||||
const propsData = {
|
||||
bookmark: {
|
||||
libraryItemId: 'library-item-1',
|
||||
time: 3661,
|
||||
title: 'Chapter note'
|
||||
},
|
||||
highlight: false,
|
||||
playbackRate: 2
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
'ui-text-input': true,
|
||||
'ui-btn': true
|
||||
}
|
||||
|
||||
it('renders bookmark timestamps from stored wall-clock time', () => {
|
||||
const mocks = {
|
||||
$secondsToTimestamp: (seconds) => {
|
||||
const totalSeconds = Math.floor(seconds)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const secs = totalSeconds % 60
|
||||
return [hours, minutes, secs].map((value) => String(value).padStart(2, '0')).join(':')
|
||||
},
|
||||
$axios: {
|
||||
$patch: cy.stub().resolves({})
|
||||
},
|
||||
$toast: {
|
||||
error: cy.stub()
|
||||
},
|
||||
$strings: {
|
||||
ToastFailedToUpdate: 'Failed to update'
|
||||
}
|
||||
}
|
||||
|
||||
cy.mount(BookmarkItem, { propsData, mocks, stubs })
|
||||
|
||||
cy.contains('01:01:01').should('be.visible')
|
||||
cy.contains('00:30:30').should('not.exist')
|
||||
})
|
||||
})
|
||||
59
client/cypress/tests/players/LocalAudioPlayer.cy.js
Normal file
59
client/cypress/tests/players/LocalAudioPlayer.cy.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import LocalAudioPlayer from '../../../players/LocalAudioPlayer'
|
||||
|
||||
describe('LocalAudioPlayer', () => {
|
||||
it('increases playbackRate during silence with the real Web Audio pipeline', () => {
|
||||
const localPlayer = new LocalAudioPlayer({})
|
||||
|
||||
expect(localPlayer.player.playbackRate).to.equal(1)
|
||||
|
||||
cy.wrap(localPlayer.setSmartSpeed(true)).then(() => {
|
||||
expect(localPlayer.enableSmartSpeed).to.be.true
|
||||
expect(localPlayer.usingWebAudio).to.be.true
|
||||
expect(localPlayer.audioContext).to.not.be.null
|
||||
expect(localPlayer.audioSourceNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode.constructor.name).to.equal('AudioWorkletNode')
|
||||
|
||||
localPlayer.player.currentTime = 5
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-start',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
|
||||
expect(localPlayer.player.playbackRate).to.equal(2.0)
|
||||
|
||||
localPlayer.player.currentTime = 8
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-end',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
|
||||
expect(localPlayer.player.playbackRate).to.equal(1.0)
|
||||
|
||||
localPlayer.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
92
client/cypress/tests/players/SmartSpeedE2E.cy.js
Normal file
92
client/cypress/tests/players/SmartSpeedE2E.cy.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import LocalAudioPlayer from '../../../players/LocalAudioPlayer'
|
||||
|
||||
function createToneSilenceToneBuffer(audioContext) {
|
||||
const sampleRate = audioContext.sampleRate
|
||||
const durationSeconds = 1.2
|
||||
const buffer = audioContext.createBuffer(1, sampleRate * durationSeconds, sampleRate)
|
||||
const channel = buffer.getChannelData(0)
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
const seconds = i / sampleRate
|
||||
const isTone = seconds < 0.3 || seconds >= 0.9
|
||||
channel[i] = isTone ? Math.sin(2 * Math.PI * 440 * seconds) * 0.25 : 0
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
describe('Smart Speed E2E with real Web Audio', () => {
|
||||
it('detects silence from real generated audio with the real AudioWorklet', () => {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext
|
||||
expect(AudioContextCtor).to.exist
|
||||
|
||||
const audioContext = new AudioContextCtor()
|
||||
const messages = []
|
||||
|
||||
cy.wrap(audioContext.audioWorklet.addModule('/smart-speed/SilenceDetectorProcessor.js')).then(() => {
|
||||
const detectorNode = new AudioWorkletNode(audioContext, 'silence-detector')
|
||||
detectorNode.port.onmessage = (event) => messages.push(event.data)
|
||||
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = createToneSilenceToneBuffer(audioContext)
|
||||
source.connect(detectorNode)
|
||||
detectorNode.connect(audioContext.destination)
|
||||
|
||||
return audioContext.resume().then(() => {
|
||||
source.start()
|
||||
return new Promise((resolve) => {
|
||||
source.onended = resolve
|
||||
})
|
||||
}).then(() => {
|
||||
detectorNode.disconnect()
|
||||
return audioContext.close()
|
||||
})
|
||||
}).then(() => {
|
||||
const silenceStart = messages.find((message) => message.type === 'silence-start')
|
||||
const silenceEnd = messages.find((message) => message.type === 'silence-end')
|
||||
|
||||
expect(silenceStart).to.exist
|
||||
expect(silenceEnd).to.exist
|
||||
expect(silenceStart.time).to.be.within(250, 450)
|
||||
expect(silenceEnd.time).to.be.within(850, 1050)
|
||||
})
|
||||
})
|
||||
|
||||
it('compresses silence in LocalAudioPlayer through the real worklet node', () => {
|
||||
const localPlayer = new LocalAudioPlayer({})
|
||||
localPlayer.smartSpeedRatio = 2.5
|
||||
localPlayer.enableSmartSpeed = true
|
||||
|
||||
cy.wrap(localPlayer.setSmartSpeed(true)).then(() => {
|
||||
expect(localPlayer.usingWebAudio).to.equal(true)
|
||||
expect(localPlayer.audioContext).to.not.be.null
|
||||
expect(localPlayer.audioSourceNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode.constructor.name).to.equal('AudioWorkletNode')
|
||||
|
||||
localPlayer.player.currentTime = 1.0
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-start',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
expect(localPlayer.player.playbackRate).to.equal(2.5)
|
||||
|
||||
localPlayer.player.currentTime = 3.0
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-end',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
expect(localPlayer.player.playbackRate).to.equal(1.0)
|
||||
|
||||
const regions = localPlayer.silenceMap.getRegions()
|
||||
expect(regions).to.have.lengthOf(1)
|
||||
expect(localPlayer.timeMapper.totalTimeSaved()).to.be.greaterThan(0)
|
||||
|
||||
localPlayer.destroy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
import SilenceMap from './smart-speed/SilenceMap'
|
||||
import TimeMapper from './smart-speed/TimeMapper'
|
||||
|
||||
export default class LocalAudioPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
|
|
@ -21,6 +23,16 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
this.playableMimeTypes = []
|
||||
|
||||
this.audioContext = null
|
||||
this.audioSourceNode = null
|
||||
this.usingWebAudio = false
|
||||
|
||||
this.silenceMap = new SilenceMap()
|
||||
this.silenceDetectorNode = null
|
||||
this.timeMapper = new TimeMapper([], 1.0)
|
||||
this.smartSpeedRatio = 2.0
|
||||
this.enableSmartSpeed = false
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +57,8 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
this.player.addEventListener('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
this.player.addEventListener('waiting', this.evtWaiting.bind(this))
|
||||
this.player.addEventListener('playing', this.evtPlaying.bind(this))
|
||||
|
||||
var mimeTypes = [
|
||||
'audio/flac',
|
||||
|
|
@ -67,6 +81,94 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
if (canPlay) this.playableMimeTypes.push(mt)
|
||||
})
|
||||
console.log(`[LocalPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||
this.initWebAudio()
|
||||
}
|
||||
|
||||
initWebAudio() {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext
|
||||
if (!AudioContextCtor) {
|
||||
console.warn('[LocalPlayer] Web Audio API not supported, falling back to direct audio')
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.audioContext = new AudioContextCtor()
|
||||
this.audioSourceNode = this.audioContext.createMediaElementSource(this.player)
|
||||
this.audioSourceNode.connect(this.audioContext.destination)
|
||||
this.usingWebAudio = true
|
||||
console.log('[LocalPlayer] Web Audio API pipeline initialised')
|
||||
} catch (err) {
|
||||
console.error('[LocalPlayer] Failed to initialise Web Audio API', err)
|
||||
this.usingWebAudio = false
|
||||
}
|
||||
}
|
||||
|
||||
updateSmartSpeedRegions() {
|
||||
this.timeMapper = new TimeMapper(this.silenceMap.getRegions(), this.smartSpeedRatio)
|
||||
this.emit('timeSaved', this.timeMapper.totalTimeSaved())
|
||||
}
|
||||
|
||||
async initSilenceDetector() {
|
||||
if (!this.usingWebAudio || !this.audioContext) return
|
||||
if (this.silenceDetectorNode) return
|
||||
|
||||
try {
|
||||
await this.audioContext.audioWorklet.addModule('/smart-speed/SilenceDetectorProcessor.js')
|
||||
this.silenceDetectorNode = new AudioWorkletNode(this.audioContext, 'silence-detector')
|
||||
|
||||
this.silenceDetectorNode.port.onmessage = (event) => {
|
||||
const msg = event.data
|
||||
if (msg.type === 'silence-start') {
|
||||
// Map AudioContext time to Media time
|
||||
const delayMs = this.audioContext.currentTime * 1000 - msg.time
|
||||
this._silenceStartTime = this.player.currentTime * 1000 - delayMs
|
||||
|
||||
// Dynamically increase playback rate
|
||||
if (this.enableSmartSpeed) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate * this.smartSpeedRatio
|
||||
}
|
||||
} else if (msg.type === 'silence-end') {
|
||||
if (this.enableSmartSpeed) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate
|
||||
}
|
||||
if (this._silenceStartTime !== null) {
|
||||
const delayMs = this.audioContext.currentTime * 1000 - msg.time
|
||||
const silenceEndTime = this.player.currentTime * 1000 - delayMs
|
||||
this.silenceMap.addRegion(this._silenceStartTime, silenceEndTime)
|
||||
this._silenceStartTime = null
|
||||
this.updateSmartSpeedRegions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.audioSourceNode.disconnect()
|
||||
this.audioSourceNode.connect(this.silenceDetectorNode)
|
||||
this.silenceDetectorNode.connect(this.audioContext.destination)
|
||||
|
||||
this._silenceStartTime = null
|
||||
console.log('[LocalPlayer] Silence detector initialised')
|
||||
} catch (err) {
|
||||
console.warn('[LocalPlayer] Failed to initialise silence detector', err)
|
||||
this.silenceDetectorNode = null
|
||||
}
|
||||
}
|
||||
|
||||
destroySilenceDetector() {
|
||||
if (this.silenceDetectorNode) {
|
||||
try {
|
||||
this.silenceDetectorNode.disconnect()
|
||||
} catch (err) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
this.silenceDetectorNode = null
|
||||
}
|
||||
this.silenceMap.reset()
|
||||
this.updateSmartSpeedRegions()
|
||||
this._silenceStartTime = null
|
||||
|
||||
// Reset playback rate in case we were in the middle of a silence region
|
||||
if (this.player) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate
|
||||
}
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
|
|
@ -113,8 +215,22 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
evtWaiting() {
|
||||
if (this.audioContext && this.audioContext.state === 'running') {
|
||||
this.audioContext.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
evtPlaying() {
|
||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroySilenceDetector()
|
||||
this.destroyHlsInstance()
|
||||
this.destroyWebAudio()
|
||||
if (this.player) {
|
||||
this.player.remove()
|
||||
}
|
||||
|
|
@ -215,6 +331,8 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
loadCurrentTrack() {
|
||||
if (!this.currentTrack) return
|
||||
this.silenceMap.reset()
|
||||
this.updateSmartSpeedRegions()
|
||||
// When direct play track is loaded current time needs to be set
|
||||
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
||||
this.player.src = this.currentTrack.relativeContentUrl
|
||||
|
|
@ -231,6 +349,26 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
this.hlsInstance = null
|
||||
}
|
||||
|
||||
destroyWebAudio() {
|
||||
if (this.audioSourceNode) {
|
||||
try {
|
||||
this.audioSourceNode.disconnect()
|
||||
} catch (err) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
this.audioSourceNode = null
|
||||
}
|
||||
if (this.audioContext) {
|
||||
try {
|
||||
this.audioContext.close()
|
||||
} catch (err) {
|
||||
// Ignore close errors
|
||||
}
|
||||
this.audioContext = null
|
||||
}
|
||||
this.usingWebAudio = false
|
||||
}
|
||||
|
||||
async resetStream(startTime) {
|
||||
this.destroyHlsInstance()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
|
@ -245,7 +383,12 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
play() {
|
||||
this.playWhenReady = true
|
||||
if (this.player) this.player.play()
|
||||
if (this.player) {
|
||||
if (this.usingWebAudio && this.audioContext && this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume()
|
||||
}
|
||||
this.player.play()
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
|
|
@ -255,37 +398,79 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
getCurrentTime() {
|
||||
var currentTrackOffset = this.currentTrack.startOffset || 0
|
||||
return this.player ? currentTrackOffset + this.player.currentTime : 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) {
|
||||
if (!this.player) return
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
this.player.playbackRate = playbackRate
|
||||
|
||||
// If we're in the middle of a silence region, we should multiply the new rate
|
||||
if (this.enableSmartSpeed && this._silenceStartTime !== null) {
|
||||
this.player.playbackRate = playbackRate * this.smartSpeedRatio
|
||||
} else {
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
}
|
||||
|
||||
async setSmartSpeed(enabled) {
|
||||
this.enableSmartSpeed = enabled
|
||||
if (enabled && this.usingWebAudio) {
|
||||
await this.initSilenceDetector()
|
||||
} else {
|
||||
this.destroySilenceDetector()
|
||||
}
|
||||
}
|
||||
|
||||
seek(time, playWhenReady) {
|
||||
if (!this.player) return
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
this.silenceMap.reset()
|
||||
this.updateSmartSpeedRegions()
|
||||
this.playWhenReady = playWhenReady
|
||||
|
||||
// Reset playback rate in case we were in a silence region
|
||||
if (this.enableSmartSpeed && this.player.playbackRate !== this.defaultPlaybackRate) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate
|
||||
}
|
||||
|
||||
if (this.isHlsTranscode) {
|
||||
// Seeking HLS stream
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
} else {
|
||||
// Seeking Direct play
|
||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
if (mappedTime < this.currentTrack.startOffset || mappedTime > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
// Change Track
|
||||
var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
|
||||
var trackIndex = this.audioTracks.findIndex((t) => mappedTime >= t.startOffset && mappedTime < t.startOffset + t.duration)
|
||||
if (trackIndex >= 0) {
|
||||
this.startTime = time
|
||||
this.startTime = mappedTime
|
||||
this.currentTrackIndex = trackIndex
|
||||
|
||||
if (!this.player.paused) {
|
||||
|
|
@ -295,7 +480,7 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
this.loadCurrentTrack()
|
||||
}
|
||||
} else {
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,6 +383,13 @@ export default class PlayerHandler {
|
|||
this.player.setPlaybackRate(playbackRate)
|
||||
}
|
||||
|
||||
setSmartSpeed(enabled, ratio = 2.5) {
|
||||
if (this.player && this.player instanceof LocalAudioPlayer) {
|
||||
this.player.smartSpeedRatio = ratio
|
||||
this.player.setSmartSpeed(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
seek(time, shouldSync = true) {
|
||||
if (!this.player) return
|
||||
this.player.seek(time, this.playerPlaying)
|
||||
|
|
|
|||
67
client/players/smart-speed/SilenceMap.js
Normal file
67
client/players/smart-speed/SilenceMap.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
class SilenceMap {
|
||||
constructor() {
|
||||
this._regions = []
|
||||
}
|
||||
|
||||
get regionCount() {
|
||||
return this._regions.length
|
||||
}
|
||||
|
||||
getRegions() {
|
||||
return [...this._regions]
|
||||
}
|
||||
|
||||
addRegion(startMs, endMs) {
|
||||
if (typeof startMs !== 'number' || typeof endMs !== 'number') return
|
||||
if (startMs < 0 || endMs < 0) return
|
||||
if (endMs <= startMs) return
|
||||
|
||||
const newRegion = { start: startMs, end: endMs }
|
||||
const merged = []
|
||||
let inserted = false
|
||||
|
||||
for (const region of this._regions) {
|
||||
if (newRegion.start <= region.end + 10 && newRegion.end >= region.start - 10) {
|
||||
newRegion.start = Math.min(newRegion.start, region.start)
|
||||
newRegion.end = Math.max(newRegion.end, region.end)
|
||||
} else if (!inserted && region.start > newRegion.end) {
|
||||
merged.push(newRegion)
|
||||
merged.push(region)
|
||||
inserted = true
|
||||
} else {
|
||||
merged.push(region)
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
merged.push(newRegion)
|
||||
}
|
||||
|
||||
this._regions = merged
|
||||
|
||||
// Cap the number of regions to prevent memory leaks for long audiobooks
|
||||
// Assuming each region is ~1 second, 5000 regions is over an hour of silence
|
||||
if (this._regions.length > 5000) {
|
||||
this._regions = this._regions.slice(-5000)
|
||||
}
|
||||
}
|
||||
|
||||
getCompressedOffset(atTimeMs, ratio) {
|
||||
if (!ratio || ratio <= 1) return 0
|
||||
let saved = 0
|
||||
for (const region of this._regions) {
|
||||
if (atTimeMs <= region.start) break
|
||||
const silenceStart = region.start
|
||||
const silenceEnd = Math.min(region.end, atTimeMs)
|
||||
const silenceDuration = silenceEnd - silenceStart
|
||||
saved += silenceDuration * (1 - 1 / ratio)
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._regions = []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SilenceMap
|
||||
89
client/players/smart-speed/TimeMapper.js
Normal file
89
client/players/smart-speed/TimeMapper.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
class TimeMapper {
|
||||
constructor(silenceRegions = [], compressionRatio = 1.0) {
|
||||
this.ratio = compressionRatio
|
||||
// Only keep regions >= 200ms
|
||||
this.regions = silenceRegions.filter(r => (r.end - r.start) >= 200)
|
||||
|
||||
// Calculate compressed durations and cumulative time saved
|
||||
this.processedRegions = []
|
||||
let accumulatedSaved = 0
|
||||
|
||||
for (const r of this.regions) {
|
||||
const originalDuration = r.end - r.start
|
||||
const compressedDuration = this.ratio === 0 ? 0 : originalDuration / this.ratio
|
||||
const saved = originalDuration - compressedDuration
|
||||
|
||||
this.processedRegions.push({
|
||||
...r,
|
||||
originalDuration,
|
||||
compressedDuration,
|
||||
saved,
|
||||
accumulatedSavedBefore: accumulatedSaved
|
||||
})
|
||||
|
||||
accumulatedSaved += saved
|
||||
}
|
||||
|
||||
this._totalTimeSaved = accumulatedSaved
|
||||
}
|
||||
|
||||
wallClockToAudio(wallMs) {
|
||||
if (this.ratio === 1.0 || this.regions.length === 0) return wallMs
|
||||
|
||||
let audioMs = wallMs
|
||||
|
||||
for (const r of this.processedRegions) {
|
||||
// The start time of this region in wall-clock time
|
||||
const regionWallStart = r.start - r.accumulatedSavedBefore
|
||||
|
||||
if (wallMs < regionWallStart) {
|
||||
// Before this region, no more accumulated saved to add
|
||||
break
|
||||
}
|
||||
|
||||
const regionWallEnd = regionWallStart + r.compressedDuration
|
||||
|
||||
if (wallMs <= regionWallEnd) {
|
||||
// Inside the compressed region
|
||||
const timeSpentInRegionWall = wallMs - regionWallStart
|
||||
const timeSpentInRegionAudio = timeSpentInRegionWall * this.ratio
|
||||
return r.start + timeSpentInRegionAudio
|
||||
}
|
||||
|
||||
// After this region, we add the total time saved by this region
|
||||
audioMs = wallMs + (r.accumulatedSavedBefore + r.saved)
|
||||
}
|
||||
|
||||
return audioMs
|
||||
}
|
||||
|
||||
audioToWallClock(audioMs) {
|
||||
if (this.ratio === 1.0 || this.regions.length === 0) return audioMs
|
||||
|
||||
let wallMs = audioMs
|
||||
|
||||
for (const r of this.processedRegions) {
|
||||
if (audioMs < r.start) {
|
||||
break
|
||||
}
|
||||
|
||||
if (audioMs <= r.end) {
|
||||
// Inside the region
|
||||
const timeSpentInRegionAudio = audioMs - r.start
|
||||
const timeSpentInRegionWall = timeSpentInRegionAudio / this.ratio
|
||||
return r.start - r.accumulatedSavedBefore + timeSpentInRegionWall
|
||||
}
|
||||
|
||||
// After the region
|
||||
wallMs = audioMs - (r.accumulatedSavedBefore + r.saved)
|
||||
}
|
||||
|
||||
return wallMs
|
||||
}
|
||||
|
||||
totalTimeSaved() {
|
||||
return this._totalTimeSaved
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimeMapper
|
||||
76
client/public/smart-speed/SilenceDetectorProcessor.js
Normal file
76
client/public/smart-speed/SilenceDetectorProcessor.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
const SPEAKING = 0
|
||||
const SILENCE = 1
|
||||
const CANDIDATE = 2
|
||||
|
||||
const DEBOUNCE_MS = 200
|
||||
const RMS_REPORT_INTERVAL = 10
|
||||
|
||||
class SilenceDetectorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.state = SPEAKING
|
||||
this.silenceThreshold = -40
|
||||
this.candidateStartSample = 0
|
||||
this.sampleRate = sampleRate
|
||||
this.blockCount = 0
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const msg = event.data
|
||||
if (msg.type === 'reset') {
|
||||
this.state = SPEAKING
|
||||
this.candidateStartSample = 0
|
||||
return
|
||||
}
|
||||
if (msg.type === 'set-threshold') {
|
||||
this.silenceThreshold = msg.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0]
|
||||
if (!input || !input.length) return true
|
||||
|
||||
const channel = input[0]
|
||||
if (!channel) return true
|
||||
|
||||
let sum = 0
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
sum += channel[i] * channel[i]
|
||||
}
|
||||
const rms = Math.sqrt(sum / channel.length)
|
||||
const dbfs = rms === 0 ? -Infinity : 20 * Math.log10(rms)
|
||||
|
||||
this.blockCount++
|
||||
|
||||
if (dbfs < this.silenceThreshold) {
|
||||
if (this.state === SPEAKING) {
|
||||
this.candidateStartSample = currentFrame
|
||||
this.state = CANDIDATE
|
||||
} else if (this.state === CANDIDATE) {
|
||||
const elapsedMs = ((currentFrame - this.candidateStartSample) / this.sampleRate) * 1000
|
||||
if (elapsedMs >= DEBOUNCE_MS) {
|
||||
this.state = SILENCE
|
||||
const silenceStartTime = (this.candidateStartSample / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-start', time: silenceStartTime })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.state === SILENCE) {
|
||||
const currentTime = (currentFrame / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-end', time: currentTime })
|
||||
}
|
||||
if (this.state !== SPEAKING) {
|
||||
this.state = SPEAKING
|
||||
}
|
||||
}
|
||||
|
||||
if (this.blockCount % RMS_REPORT_INTERVAL === 0) {
|
||||
this.port.postMessage({ type: 'rms', value: dbfs })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('silence-detector', SilenceDetectorProcessor)
|
||||
76
client/static/smart-speed/SilenceDetectorProcessor.js
Normal file
76
client/static/smart-speed/SilenceDetectorProcessor.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
const SPEAKING = 0
|
||||
const SILENCE = 1
|
||||
const CANDIDATE = 2
|
||||
|
||||
const DEBOUNCE_MS = 200
|
||||
const RMS_REPORT_INTERVAL = 10
|
||||
|
||||
class SilenceDetectorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.state = SPEAKING
|
||||
this.silenceThreshold = -40
|
||||
this.candidateStartSample = 0
|
||||
this.sampleRate = sampleRate
|
||||
this.blockCount = 0
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const msg = event.data
|
||||
if (msg.type === 'reset') {
|
||||
this.state = SPEAKING
|
||||
this.candidateStartSample = 0
|
||||
return
|
||||
}
|
||||
if (msg.type === 'set-threshold') {
|
||||
this.silenceThreshold = msg.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0]
|
||||
if (!input || !input.length) return true
|
||||
|
||||
const channel = input[0]
|
||||
if (!channel) return true
|
||||
|
||||
let sum = 0
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
sum += channel[i] * channel[i]
|
||||
}
|
||||
const rms = Math.sqrt(sum / channel.length)
|
||||
const dbfs = rms === 0 ? -Infinity : 20 * Math.log10(rms)
|
||||
|
||||
this.blockCount++
|
||||
|
||||
if (dbfs < this.silenceThreshold) {
|
||||
if (this.state === SPEAKING) {
|
||||
this.candidateStartSample = currentFrame
|
||||
this.state = CANDIDATE
|
||||
} else if (this.state === CANDIDATE) {
|
||||
const elapsedMs = ((currentFrame - this.candidateStartSample) / this.sampleRate) * 1000
|
||||
if (elapsedMs >= DEBOUNCE_MS) {
|
||||
this.state = SILENCE
|
||||
const silenceStartTime = (this.candidateStartSample / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-start', time: silenceStartTime })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.state === SILENCE) {
|
||||
const currentTime = (currentFrame / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-end', time: currentTime })
|
||||
}
|
||||
if (this.state !== SPEAKING) {
|
||||
this.state = SPEAKING
|
||||
}
|
||||
}
|
||||
|
||||
if (this.blockCount % RMS_REPORT_INTERVAL === 0) {
|
||||
this.port.postMessage({ type: 'rms', value: dbfs })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('silence-detector', SilenceDetectorProcessor)
|
||||
|
|
@ -18,7 +18,9 @@ export const state = () => ({
|
|||
authorSortBy: 'name',
|
||||
authorSortDesc: false,
|
||||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10
|
||||
jumpBackwardAmount: 10,
|
||||
enableSmartSpeed: false,
|
||||
smartSpeedRatio: 2.5
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -199,5 +201,17 @@ export const mutations = {
|
|||
if (!settings) return
|
||||
localStorage.setItem('userSettings', JSON.stringify(settings))
|
||||
state.settings = settings
|
||||
},
|
||||
SET_SMART_SPEED_ENABLED(state, enabled) {
|
||||
state.settings.enableSmartSpeed = enabled !== undefined ? enabled : !state.settings.enableSmartSpeed
|
||||
localStorage.setItem('userSettings', JSON.stringify(state.settings))
|
||||
},
|
||||
SET_SMART_SPEED_RATIO(state, ratio) {
|
||||
let clampedRatio = Number(ratio)
|
||||
if (isNaN(clampedRatio)) clampedRatio = 2.5
|
||||
if (clampedRatio < 1.5) clampedRatio = 1.5
|
||||
if (clampedRatio > 5.0) clampedRatio = 5.0
|
||||
state.settings.smartSpeedRatio = clampedRatio
|
||||
localStorage.setItem('userSettings', JSON.stringify(state.settings))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -712,6 +712,8 @@
|
|||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseAdvancedOptions": "Use Advanced Options",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelEnableSmartSpeed": "Enable Smart Speed",
|
||||
"LabelSmartSpeedRatio": "Smart Speed Compression Ratio",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUseZeroForUnlimited": "Use 0 for unlimited",
|
||||
"LabelUser": "User",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue