Files
readest/apps/readest-app/src/utils/time.ts
T
Huang Xin 42f9b8fe3c feat(tts): gapless Web Audio playback engine for Edge TTS with chapter timeline and seek (#4931)
* feat(tts): add PCM speech-bounds detection for sentence audio trimming

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add WSOLA time-stretch for pitch-preserved playback rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add sentence duration store with per-voice speaking-rate calibration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): play edge TTS through gapless Web Audio pipeline

Replaces the per-sentence audio element with trimmed, time-stretched
buffers scheduled on the shared AudioContext. Marks dispatch at audible
time so schedule-ahead cannot run foliate's cursor past the voice; a
decode failure or missing audio skips the chunk instead of wedging the
session; pause and resume ride context suspend and resume with no iOS
rewind hack; the object-URL cache is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add section timeline with measured and estimated sentence durations

Includes the foliate-js submodule bump for the getSentences export
(fork branch feat/tts-get-sentences; fork PR must merge before this
lands so the pinned SHA resolves).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): expose section playback position and sentence-snapped seeking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): surface playback position and seek in the media session

Position state is clamped, never skipped, so the lock-screen scrubber
stays live when estimates overshoot; seekto units map per backend
(native ms, web seconds). The AudioContext warms up in the tts-speak
gesture path before any await, and the silent keep-alive element now
runs on all platforms so desktop hardware media keys survive the
removal of the per-sentence audio element.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add seekable chapter progress bar to the TTS panel

The scrubber joins the transport cluster with a thin range-xs track and
flanking tabular time labels so it cannot be misgrabbed for the chunky
rate slider (which persists a global setting). States: reserved
disabled slot until the lazy timeline lands, persists across chapter
transitions, optimistic thumb with failure toast, monotonic position,
tilde-prefixed estimated totals, sentence-event updates under e-ink.
The popup grows only when a timeline-capable client is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record deferred TTS listening-engine follow-ups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record background TTS decoupling design decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): slim the panel scrubber to a native track with remaining time

Match the footer Jump to Location slider (plain native range: thin
track, small thumb) instead of the chunky daisyUI pill, show remaining
time with a minus prefix on the right, and drop the This chapter
caption. Popup height shrinks accordingly. Verified live in Chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: pin foliate-js to merged main with getSentences export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tts): catch autoplay rejection from the keep-alive element

Running the silent keep-alive on all platforms exposed an un-awaited
play() that headless Chromium rejects without a user gesture, failing
CI on unhandled rejections while every test passed. The keep-alive is
best-effort; the production path is gesture-qualified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:50:22 +02:00

52 lines
1.6 KiB
TypeScript

import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
import 'dayjs/locale/en';
import 'dayjs/locale/zh';
import 'dayjs/locale/de';
import 'dayjs/locale/ja';
import 'dayjs/locale/ko';
import 'dayjs/locale/ru';
import 'dayjs/locale/fr';
import 'dayjs/locale/el';
import 'dayjs/locale/es';
import 'dayjs/locale/it';
import 'dayjs/locale/pt';
import 'dayjs/locale/pt-br';
import 'dayjs/locale/ar';
import 'dayjs/locale/id';
import 'dayjs/locale/hi';
import 'dayjs/locale/th';
import 'dayjs/locale/tr';
import 'dayjs/locale/vi';
import 'dayjs/locale/uk';
import 'dayjs/locale/pl';
import 'dayjs/locale/fi';
import 'dayjs/locale/nl';
import 'dayjs/locale/ro';
import 'dayjs/locale/zh-tw';
import 'dayjs/locale/zh-cn';
export const initDayjs = (locale: string) => {
dayjs.locale(locale);
dayjs.extend(relativeTime);
};
// Clock-style playback time for the TTS scrubber: m:ss below one hour,
// h:mm:ss above. Pass forceHours so both labels of a row share the format
// chosen by the total's magnitude and the row never re-layouts when the
// elapsed side crosses an hour.
export const formatPlaybackTime = (seconds: number, forceHours = false): string => {
const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0 || forceHours) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
};