42f9b8fe3c
* 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>
52 lines
1.6 KiB
TypeScript
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')}`;
|
|
};
|