-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
import { strip_tags } from './dom-utils.js';
import { customAlert } from './confirm-dialog.js';
import { calculateTtsMeasuredCost } from './settings-utils.js';
import Api from '../api.js';
import Storage from '../storage.js';
let state = 'idle';
let player = null;
let abort = null;
let chain_token = 0;
let active_index = null;
let active_block = null;
let auto_advance = false;
let auto_play = false;
const seen_reply_tops = new Map();
const active_sources = [];
let container = null;
let get_settings = null;
let get_title = null;
let get_guid = null;
let get_storage = null;
let audio_ctx = null;
const api = new Api();
const storage = new Storage();
let speech_job = null;
/** Pangrams (each covering all 26 letters) randomly chosen for the voice preview. */
const PREVIEW_PANGRAMS = [
'The quick brown fox jumps over the lazy dog.',
'They wove six dozen green and mauve plaid jackets before they quit.',
'Two driven jumpy tutors fixed my big quiz fast.',
'The zoo gave a quiz on pigs, foxes, zebras, wolves, jaguars and a quail.',
'Sphinx of black quartz, judge my vow.',
'Pack my box with five dozen bags of liquorice jelly beans.'
];
let preview_state = 'idle';
let preview_btn = null;
let preview_get_settings = null;
let preview_token = 0;
/**
* Binds the prev / play / next buttons and the auto-advance and auto-play
* switches of conversation and scratchpad response toolbars. The middle
* button reads the matching response through the TTS endpoint: first the
* reply's title with a command tone, then each content block (header,
* paragraph, ...) with a neutral respect tone, highlighting the text currently
* being read. Audio is streamed and cached locally per conversation, response
* block and voice, so repeated readbacks do not synthesize again. Previous
* and next move the readback one block; only one readback runs at a time.
* Previous / next are dimmed when no adjacent item exists to move to. Each
* finished entry's estimated read cost is attached to it, saved on the
* conversation's history item, and summed in the toolbar's cost readout.
* @param {HTMLElement} container_el - Container holding the toolbars and response contents.
* @param {Function|null} [settings_getter=null] - Returns the configured TTS
* settings { tts_model, voice } sent with each synthesis.
* @param {Function|null} [title_getter=null] - Returns the reply title for a
* response item index, read before its content.
* @param {Function|null} [guid_getter=null] - Returns the conversation guid
* used to key the audio cache.
* @param {Function|null} [storage_getter=null] - Returns the app's initialized
* storage instance, used to
* persist the read costs.
*/
export function bind_read_buttons(container_el, settings_getter = null, title_getter = null, guid_getter = null, storage_getter = null) {
container = container_el;
get_settings = settings_getter;
get_title = title_getter;
get_guid = guid_getter;
get_storage = storage_getter;
// a position kept from another conversation's render no longer exists here
if (active_index !== null && !read_indexes().includes(Number(active_index))) {
active_index = null;
active_block = null;
}
container.querySelectorAll('.btn-read-prev, .btn-read-toggle, .btn-read-next').forEach((btn) => {
btn.onclick = () => {
if (btn.classList.contains('btn-read-prev')) step_reading(-1);
else if (btn.classList.contains('btn-read-next')) step_reading(1);
else toggle_reading(btn.getAttribute('data-index'));
};
});
container.querySelectorAll('.input-read-auto').forEach((input) => {
input.onchange = () => {
auto_advance = input.checked;
sync_read_ui();
};
});
container.querySelectorAll('.input-read-autoplay').forEach((input) => {
input.onchange = () => {
auto_play = input.checked;
sync_read_ui();
};
});
restore_read_costs();
sync_read_ui();
}
/**
* Re-applies the read costs saved on the conversation's history items to the
* rendered read entries and refreshes the cost readouts, so a re-rendered or
* reloaded view shows the summed cost immediately.
*/
function restore_read_costs() {
read_indexes().forEach((index) => {
const costs = read_costs_of(index);
Object.entries(costs).forEach(([key, cost]) => {
const el = key === 'title' ? find_header(index) : blocks_of(index)[Number(key)];
if (el) el.dataset.readCost = String(cost);
});
});
update_read_costs();
}
/**
* Returns the app's initialized storage instance, or null when unbound. The
* module-level Storage instance never runs init(), so its conversation keys
* are unset and it must not be used for conversation data.
* @returns {Object|null}
*/
function current_storage() {
return typeof get_storage === 'function' ? (get_storage() || null) : null;
}
/**
* Returns the saved read costs of one response item.
* @param {number} index - The response item index.
* @returns {Object} Map of entry key ('title' or block number) to cost.
*/
function read_costs_of(index) {
const storage = current_storage();
const guid = typeof get_guid === 'function' ? (get_guid() || '') : '';
const conversation = guid && storage ? storage.get_conversation(guid) : null;
const item = conversation?.[storage.KEY_CONVERSATION_HISTORY]?.[Number(index)];
return item?.[storage.KEY_CONVERSATION_READ_COSTS] || {};
}
/**
* Saves the estimated read cost of one entry on the conversation's history
* item, keyed by title or block number, so it survives re-renders.
* @param {{item: number, block: number}} pos - The readback position.
* @param {boolean} is_title - Whether the entry reads the reply title.
* @param {number} cost - The estimated cost in dollars.
*/
function save_entry_cost(pos, is_title, cost) {
const storage = current_storage();
const guid = typeof get_guid === 'function' ? (get_guid() || '') : '';
if (!storage || !guid || !pos) return;
const conversation = storage.get_conversation(guid);
const item = conversation?.[storage.KEY_CONVERSATION_HISTORY]?.[Number(pos.item)];
if (!item) return;
const costs = item[storage.KEY_CONVERSATION_READ_COSTS] || {};
costs[is_title ? 'title' : String(pos.block)] = cost;
item[storage.KEY_CONVERSATION_READ_COSTS] = costs;
// quiet: the entry cost is already rendered, and a re-render here would
// cancel the smooth scroll to the item being read
storage.save_conversation(guid, conversation, true);
}
/**
* Starts reading the newest reply automatically when auto-play is on and a
* new reply index appeared for the conversation since the last render. The
* first render of a conversation only records the baseline.
* @param {string} guid - The conversation guid of the rendered view.
*/
export function check_autoplay(guid) {
if (!guid || !auto_play) return;
const indexes = read_indexes();
const top = indexes.length ? indexes[indexes.length - 1] : null;
if (top === null) return;
const seen = seen_reply_tops.get(guid);
seen_reply_tops.set(guid, top);
if (seen === undefined || top <= seen) return;
if (state !== 'idle') return;
start_chain({ item: top, block: 0 });
}
/**
* Starts or stops readback of a response item.
* @param {string} index - The response item index.
*/
function toggle_reading(index) {
if (state !== 'idle' && String(active_index) === String(index)) {
stop_reading();
return;
}
start_chain({ item: Number(index), block: 0 });
}
/**
* Moves readback one block in the given direction, when the boundary allows.
* @param {number} dir - -1 for previous, 1 for next.
*/
function step_reading(dir) {
const target = step_position(current_pos(), dir);
if (target === null) return;
start_chain(target);
}
/**
* Returns the current playback position, or null when nothing was read yet.
* @returns {{item: number, block: number}|null}
*/
function current_pos() {
if (active_index === null || active_block === null) return null;
return { item: Number(active_index), block: active_block };
}
/**
* Returns the block position adjacent to the given one in a direction,
* respecting the boundary: the response when auto-advance is off, the whole
* conversation when it is on. Items without readable blocks are skipped, and
* null is returned at the boundary so no audio is generated past the end.
* @param {{item: number, block: number}|null} pos - The origin position.
* @param {number} dir - -1 for previous, 1 for next.
* @returns {{item: number, block: number}|null}
*/
function step_position(pos, dir) {
if (!pos) return null;
if (dir < 0) {
if (pos.block > 0) return { item: pos.item, block: pos.block - 1 };
if (!auto_advance) return null;
let prev = adjacent_item(pos.item, -1);
while (prev !== null && blocks_of(prev).length === 0) {
prev = adjacent_item(prev, -1);
}
if (prev === null) return null;
const len = blocks_of(prev).length;
return len > 0 ? { item: prev, block: len - 1 } : null;
}
if (pos.block + 1 < blocks_of(pos.item).length) return { item: pos.item, block: pos.block + 1 };
if (!auto_advance) return null;
let next = adjacent_item(pos.item, 1);
while (next !== null && blocks_of(next).length === 0) {
next = adjacent_item(next, 1);
}
return next === null ? null : { item: next, block: 0 };
}
/**
* Returns the response item index adjacent to the given one in a direction,
* or null at the conversation boundary.
* @param {number} item - The response item index.
* @param {number} dir - -1 for previous, 1 for next.
* @returns {number|null}
*/
function adjacent_item(item, dir) {
const indexes = read_indexes();
const pos = indexes.indexOf(item);
if (pos === -1) return null;
const target = indexes[pos + dir];
return target === undefined ? null : target;
}
/**
* Returns the sorted response item indexes present in the container.
* @returns {number[]}
*/
function read_indexes() {
if (!container) return [];
return [...container.querySelectorAll('.div-response-content[data-index]')]
.map(el => parseInt(el.dataset.index, 10))
.filter(Number.isInteger)
.sort((a, b) => a - b);
}
/**
* Returns the top-level block elements of a response content (headers,
* paragraphs, lists, code blocks, ...), skipping blocks without text.
* @param {HTMLElement} content_el - The response content element.
* @returns {HTMLElement[]}
*/
function content_blocks(content_el) {
const blocks = [...content_el.children].filter(el => strip_tags(el.innerHTML).trim() !== '');
if (blocks.length === 0 && strip_tags(content_el.innerHTML).trim() !== '') {
return [content_el];
}
return blocks;
}
/**
* Returns the block elements of a response item.
* @param {number} item - The response item index.
* @returns {HTMLElement[]}
*/
function blocks_of(item) {
const content = container?.querySelector(`.div-response-content[data-index="${item}"]`);
return content ? content_blocks(content) : [];
}
/**
* Returns the block element at a position, or null when it does not exist.
* @param {{item: number, block: number}} pos - The position.
* @returns {HTMLElement|null}
*/
function block_at(pos) {
return blocks_of(pos.item)[pos.block] || null;
}
/**
* Stops any current readback and reads blocks starting at the given
* position: each reply begins with its title (command tone), then each
* top-level content block is synthesized and played in order (neutral
* respect tone) while it is highlighted. With auto-advance on the chain
* continues across response items until the conversation boundary; otherwise
* it stops at the end of the response. No synthesis is started past the last
* readable block.
* @param {{item: number, block: number}} start - The starting position.
*/
async function start_chain(start) {
stop_reading();
active_index = String(start.item);
active_block = start.block;
sync_read_ui();
const token = ++chain_token;
let pos = start;
while (true) {
if (token !== chain_token) return;
if (pos.block === 0) {
const title = typeof get_title === 'function' ? (get_title(pos.item) || '').trim() : '';
if (title && !(await read_entry(token, title, 'command', find_header(pos.item), pos, true))) return;
}
const block = block_at(pos);
if (!block) break;
const text = strip_tags(block.innerHTML).trim();
if (text && !(await read_entry(token, text, 'neutral respect', block, pos, false))) return;
const next = step_position(pos, 1);
if (next === null) break;
pos = next;
active_index = String(pos.item);
active_block = pos.block;
sync_read_ui();
}
clear_highlight();
set_state('idle');
}
/**
* Reads one text within the active chain: plays it from the audio cache when
* it was synthesized before, otherwise streams it from the TTS endpoint while
* playing it and stores it in the cache. Highlights the element while it
* plays. A failed or stopped entry ends the chain so no further audio is
* generated. When the entry finishes, its read cost (from the measured audio
* duration) is attached to the element and saved on the history item.
* @param {number} token - The chain token that owns this readback.
* @param {string} text - The text to speak.
* @param {string} tone - The speech tone hint for this text.
* @param {HTMLElement|null} el - The element to highlight, if any.
* @param {{item: number, block: number}} pos - The readback position.
* @param {boolean} is_title - Whether this entry reads the reply title.
* @returns {Promise<boolean>} True when the chain may continue.
*/
async function read_entry(token, text, tone, el, pos, is_title) {
if (el) {
clear_highlight();
el.classList.add('response-reading');
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
set_state('loading');
const key = audio_key(pos, is_title, tone, text);
let cached = null;
try {
cached = await storage.get_cached_audio(key);
} catch (err) {
cached = null;
}
if (token !== chain_token) return false;
const result = cached ? await play_cached(cached) : await stream_entry(token, text, tone, key);
if (token !== chain_token) return false;
if (!result.played) {
clear_highlight();
set_state('idle');
return false;
}
if (el) {
const cost = tts_entry_cost(text, result.duration);
el.dataset.readCost = String(cost);
save_entry_cost(pos, is_title, cost);
update_read_costs();
}
return true;
}
/**
* Returns the readback cost of one entry: the measured audio duration drives
* the audio tokens and the word count the input tokens, on the currently
* configured TTS model.
* @param {string} text - The plain text that was spoken.
* @param {number} duration - The measured audio duration in seconds.
* @returns {number} The cost in dollars.
*/
function tts_entry_cost(text, duration) {
return calculateTtsMeasuredCost(text, duration, current_settings().tts_model || '');
}
/**
* Returns the rendered title header of a response item, when the view shows
* one (chat replies render their query as an h5 header).
* @param {number} item - The response item index.
* @returns {HTMLElement|null}
*/
function find_header(item) {
const toggle = container?.querySelector(`.btn-read-toggle[data-index="${item}"]`);
const sibling = toggle?.closest('.div-chat-response-buttons')?.previousElementSibling;
return sibling && sibling.tagName === 'H5' ? sibling : null;
}
/**
* Returns the configured TTS settings when a settings getter is bound
* (empty values keep the endpoint defaults).
* @returns {Object} The settings { tts_model, voice }.
*/
function current_settings() {
return typeof get_settings === 'function' ? (get_settings() || {}) : {};
}
/**
* Builds the request payload for one synthesis, adding the given settings
* when they are set (empty values keep the endpoint defaults).
* @param {Object} settings - The TTS settings { tts_model, voice }.
* @param {string} text - The text to speak.
* @param {string} tone - The speech tone hint for this text, or empty.
* @param {boolean} [stream=false] - Whether to request streamed audio.
* @returns {Object} The request payload.
*/
function synthesis_payload(settings, text, tone, stream = false) {
const payload = { action: 'synthesize', text: text };
if (tone) payload.tone = tone;
if (settings.tts_model) payload.tts_model = settings.tts_model;
if (settings.voice) payload.voice = settings.voice;
if (stream) payload.stream = true;
return payload;
}
/**
* Builds the audio cache key for one readback entry: conversation, response
* item, block (or title), tone, voice, model, rate, and a hash of the text.
* @param {{item: number, block: number}} pos - The readback position.
* @param {boolean} is_title - Whether the entry reads the reply title.
* @param {string} tone - The speech tone hint for this text.
* @param {string} text - The text to speak.
* @returns {string} The cache key.
*/
function audio_key(pos, is_title, tone, text) {
const settings = current_settings();
const guid = typeof get_guid === 'function' ? (get_guid() || '') : '';
return [
guid,
pos ? pos.item : '',
is_title ? 'title' : (pos ? pos.block : ''),
tone || '',
settings.voice || '',
settings.tts_model || '',
hash_string(text)
].join('|');
}
/**
* Hashes a string with the djb2 algorithm for cache keys.
* @param {string} text - The text to hash.
* @returns {string} The hash as a base-36 string.
*/
function hash_string(text) {
let hash = 5381;
for (let i = 0; i < text.length; i++) {
hash = (((hash << 5) + hash) + text.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
/**
* Returns the shared audio context, creating it on first use and resuming it
* when suspended. Resuming may fail without a user gesture.
* @returns {Promise<AudioContext>}
*/
async function ensure_context() {
if (!audio_ctx) {
audio_ctx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audio_ctx.state === 'suspended') {
await audio_ctx.resume();
}
return audio_ctx;
}
/**
* Schedules one decoded audio buffer on the context and resolves when it
* finishes playing (or is stopped).
* @param {AudioContext} ctx - The audio context.
* @param {AudioBuffer} buffer - The decoded audio.
* @param {number} delay - Seconds to wait before playback starts.
* @returns {Promise<void>}
*/
function schedule_buffer(ctx, buffer, delay) {
return new Promise((resolve) => {
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => {
const pos = active_sources.indexOf(source);
if (pos !== -1) active_sources.splice(pos, 1);
resolve();
};
source.start(ctx.currentTime + delay);
active_sources.push(source);
});
}
/**
* Plays a cached WAV blob and resolves when playback ends or fails.
* @param {Blob} blob - The cached WAV audio.
* @returns {Promise<{played: boolean, duration: number}>} played is true when
* it played to the end; duration is the decoded audio length in
* seconds.
*/
async function play_cached(blob) {
try {
const ctx = await ensure_context();
const buffer = await ctx.decodeAudioData(await blob.arrayBuffer());
set_state('playing');
await schedule_buffer(ctx, buffer, 0.05);
return { played: true, duration: buffer.duration };
} catch (err) {
return { played: false, duration: 0 };
}
}
/**
* Builds the 44-byte RIFF/WAVE header for mono 16-bit PCM audio.
* @param {number} data_length - The PCM byte length.
* @param {number} rate - The sample rate in Hz.
* @param {number} channels - The channel count.
* @returns {Uint8Array} The header bytes.
*/
function wav_header(data_length, rate, channels) {
const view = new DataView(new ArrayBuffer(44));
const text = (offset, str) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
text(0, 'RIFF');
view.setUint32(4, 36 + data_length, true);
text(8, 'WAVE');
text(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * channels * 2, true);
view.setUint16(32, channels * 2, true);
view.setUint16(34, 16, true);
text(36, 'data');
view.setUint32(40, data_length, true);
return new Uint8Array(view.buffer);
}
/**
* Creates a progressive WAV player: it parses the WAV header from the fed
* bytes, schedules the following PCM samples on the audio context as they
* arrive and resolves done() when the last scheduled sample finishes. It also
* collects the PCM samples so the complete audio can be stored in the cache.
* @param {Function} on_start - Called when the first sample is scheduled.
* @returns {Object} Controller with feed(Uint8Array), end(), stop(),
* get_wav() returning the complete audio as a Blob,
* get_duration() returning the played seconds, and
* the done promise.
*/
function create_wav_player(on_start) {
let rate = 0;
let channels = 1;
let buf = new Uint8Array(0);
const pcm_chunks = [];
let pcm_length = 0;
let next_time = 0;
let last_source = null;
let stream_done = false;
let stopped = false;
let resolve_done = null;
const done = new Promise((resolve) => { resolve_done = resolve; });
const check_done = () => {
if (stream_done && (last_source === null || last_source.__ended)) {
resolve_done();
}
};
const feed = (bytes) => {
if (stopped) return;
const merged = new Uint8Array(buf.length + bytes.length);
merged.set(buf);
merged.set(bytes, buf.length);
buf = merged;
if (rate === 0) {
if (buf.length < 44) return;
const view = new DataView(buf.buffer, buf.byteOffset, 44);
rate = view.getUint32(24, true);
channels = Math.max(1, view.getUint16(22, true));
buf = buf.subarray(44);
next_time = audio_ctx.currentTime + 0.08;
}
const frame_bytes = 2 * channels;
const frames = Math.floor(buf.length / frame_bytes);
if (frames === 0) return;
const usable = frames * frame_bytes;
const pcm = new Int16Array(buf.buffer, buf.byteOffset, frames * channels);
const buffer = audio_ctx.createBuffer(channels, frames, rate);
for (let ch = 0; ch < channels; ch++) {
const out = buffer.getChannelData(ch);
for (let i = 0; i < frames; i++) {
out[i] = pcm[i * channels + ch] / 32768;
}
}
pcm_chunks.push(buf.subarray(0, usable));
pcm_length += usable;
buf = buf.subarray(usable);
const source = audio_ctx.createBufferSource();
source.buffer = buffer;
source.connect(audio_ctx.destination);
source.__ended = false;
source.onended = () => {
source.__ended = true;
const pos = active_sources.indexOf(source);
if (pos !== -1) active_sources.splice(pos, 1);
if (last_source === source) check_done();
};
source.start(next_time);
next_time += frames / rate;
last_source = source;
active_sources.push(source);
if (active_sources.length === 1 && typeof on_start === 'function') on_start();
};
const end = () => {
stream_done = true;
check_done();
};
const stop = () => {
stopped = true;
stream_done = true;
resolve_done();
};
const get_wav = () => {
if (rate === 0 || pcm_length === 0) return null;
return new Blob([wav_header(pcm_length, rate, channels), ...pcm_chunks], { type: 'audio/wav' });
};
const get_duration = () => rate > 0 ? pcm_length / (rate * 2 * channels) : 0;
return { feed, end, stop, get_wav, get_duration, done };
}
/**
* Streams one synthesis from the TTS endpoint and plays the audio as it
* arrives. Runs on the shared abort controller and player, so stop_reading()
* cancels it.
* @param {string} text - The text to speak.
* @param {string} tone - The speech tone hint for this text, or empty.
* @param {Object} settings - The TTS settings { tts_model, voice }.
* @param {string} error_title - Alert title shown when the synthesis fails.
* @param {Function} on_playing - Called when the first audio sample plays.
* @returns {Promise<{played: boolean, wav: Blob|null, duration: number}>}
* played is true when the audio finished; wav is the complete audio
* when available; duration is the measured audio length in seconds.
*/
async function play_streamed(text, tone, settings, error_title, on_playing) {
const controller = new AbortController();
abort = controller;
try {
await ensure_context();
} catch (err) {
return { played: false, wav: null, duration: 0 };
}
const entry_player = create_wav_player(on_playing);
player = entry_player;
try {
await api.post_stream(
synthesis_payload(settings, text, tone, true),
(chunk) => entry_player.feed(chunk),
'tts',
controller.signal
);
entry_player.end();
} catch (err) {
entry_player.stop();
if (!(controller.signal.aborted || err?.status === 'aborted' || err?.name === 'AbortError')) {
customAlert(error_title, String(err?.message || err));
}
return { played: false, wav: null, duration: 0 };
}
await entry_player.done;
return { played: true, wav: entry_player.get_wav(), duration: entry_player.get_duration() };
}
/**
* Streams one synthesis from the TTS endpoint, plays the audio as it arrives
* and stores the complete audio in the cache. A failed, stopped, or superseded
* readback ends the chain so no further audio is generated.
* @param {number} token - The chain token that owns this readback.
* @param {string} text - The text to speak.
* @param {string} tone - The speech tone hint for this text.
* @param {string} key - The audio cache key for this entry.
* @returns {Promise<{played: boolean, duration: number}>} played is true when
* the audio finished; duration is the measured audio length in
* seconds.
*/
async function stream_entry(token, text, tone, key) {
const { played, duration, wav } = await play_streamed(text, tone, current_settings(), 'Read Failed', () => set_state('playing'));
if (!played || token !== chain_token) return { played: false, duration: 0 };
if (wav) {
storage.save_cached_audio(key, wav).catch(() => {});
}
return { played: true, duration };
}
/**
* Stops any running readback or voice preview playback.
*/
export function stop_playback() {
stop_reading();
}
/**
* Reads plain text aloud through the TTS endpoint, stopping any running
* readback, preview, or speech first. Only one audio playback runs at a time
* across the app, so being stopped by another playback fires the done
* callback early with false.
* @param {string} text - The plain text to speak.
* @param {Object} [settings={}] - The TTS settings { tts_model, voice }.
* @param {Function|null} [on_playing=null] - Called when the first audio sample plays.
* @param {Function|null} [on_done=null] - Called with true when playback
* finished, false when it was stopped.
*/
export async function speak_text(text, settings = {}, on_playing = null, on_done = null) {
const job = { fired: false, cb: on_done };
stop_reading();
speech_job = job;
let played = false;
try {
const result = await play_streamed(text, '', settings || {}, 'Read Aloud Failed', on_playing);
played = result.played;
} finally {
if (!job.fired) {
job.fired = true;
if (typeof job.cb === 'function') job.cb(played);
}
if (speech_job === job) {
speech_job = null;
}
}
}
/**
* Stops readback: invalidates the block chain, aborts an in-flight synthesis
* request, stops and releases playing audio, and clears the highlight. The
* active position is kept so previous / next can continue from it.
*/
function stop_reading() {
chain_token++;
preview_token++;
abort?.abort();
abort = null;
if (player) {
player.stop();
player = null;
}
while (active_sources.length > 0) {
const source = active_sources.pop();
try {
source.stop();
} catch (err) {
// already stopped
}
}
preview_state = 'idle';
sync_preview_ui();
clear_highlight();
set_state('idle');
if (speech_job) {
const job = speech_job;
speech_job = null;
job.fired = true;
if (typeof job.cb === 'function') job.cb(false);
}
}
/**
* Removes the reading highlight from all blocks in the container.
*/
function clear_highlight() {
container?.querySelectorAll('.response-reading').forEach(el => el.classList.remove('response-reading'));
}
/**
* Updates the playing state and refreshes the toolbar buttons.
* @param {string} next - New state: idle, loading, or playing.
*/
function set_state(next) {
state = next;
sync_read_ui();
}
/**
* Refreshes the toolbar controls: the middle button shows a pause glyph
* while the active readback runs and a play glyph otherwise, previous /
* next are dimmed at the boundary, and the auto-advance switches reflect
* the current option.
*/
function sync_read_ui() {
if (!container) return;
const pos = current_pos();
container.querySelectorAll('.btn-read-toggle').forEach((btn) => {
const active = state !== 'idle' && String(active_index) === btn.getAttribute('data-index');
btn.textContent = active ? '\u23F8' : '\u25B6';
btn.title = active ? 'Stop' : 'Read aloud';
});
container.querySelectorAll('.btn-read-prev, .btn-read-next').forEach((btn) => {
const dir = btn.classList.contains('btn-read-prev') ? -1 : 1;
btn.classList.toggle('btn-read-dim', step_position(pos, dir) === null);
});
container.querySelectorAll('.span-read-loading').forEach((dot) => {
const active = state !== 'idle' && String(active_index) === dot.getAttribute('data-index');
dot.style.display = active ? 'inline-block' : 'none';
});
container.querySelectorAll('.input-read-auto').forEach((input) => {
input.checked = auto_advance;
});
container.querySelectorAll('.input-read-autoplay').forEach((input) => {
input.checked = auto_play;
});
}
/**
* Refreshes the cost readout of every response toolbar: each entry read so
* far carries its estimated cost (dataset.readCost); the readout sums those
* costs next to the auto-play switch and shows the character share they cover
* of all entries the header reads. It is hidden until the first entry has a
* cost, and the percentage is dropped once every entry is covered, with the
* cost shown in bold.
*/
function update_read_costs() {
if (!container) return;
container.querySelectorAll('.div-chat-response-buttons').forEach((toolbar) => {
const toggle = toolbar.querySelector('.btn-read-toggle');
if (!toggle) return;
const item = toggle.getAttribute('data-index');
let total_chars = 0;
let cost_chars = 0;
let cost = 0;
read_entries_of(item).forEach((el) => {
const text = strip_tags(el.innerHTML).trim();
if (!text) return;
total_chars += text.length;
if (el.dataset.readCost !== undefined) {
cost_chars += text.length;
cost += parseFloat(el.dataset.readCost) || 0;
}
});
sync_toolbar_cost(
toolbar,
cost,
total_chars > 0 && cost_chars === total_chars,
total_chars > 0 ? Math.round(cost_chars / total_chars * 100) : 0
);
});
}
/**
* Returns the elements a header reads for one response item: its rendered
* title header followed by the response content's readable blocks.
* @param {string} item - The response item index.
* @returns {HTMLElement[]}
*/
function read_entries_of(item) {
const entries = [];
const header = find_header(item);
if (header) entries.push(header);
const content = container?.querySelector(`.div-response-content[data-index="${item}"]`);
if (content) entries.push(...content_blocks(content));
return entries;
}
/**
* Renders one toolbar's cost readout next to the auto-play switch, replacing
* any previous value; removes the readout when there is nothing to show.
* @param {HTMLElement} toolbar - The response toolbar.
* @param {number} cost - The summed cost of the entries read so far.
* @param {boolean} complete - Whether every readable entry carries a cost.
* @param {number} percent - Character share of the entries read so far.
*/
function sync_toolbar_cost(toolbar, cost, complete, percent) {
const controls = toolbar.querySelector('.div-chat-read-controls');
if (!controls) return;
let span = controls.querySelector('.span-read-cost');
if (cost <= 0) {
span?.remove();
return;
}
if (!span) {
span = document.createElement('span');
span.className = 'span-read-cost';
const autoplay = controls.querySelector('.input-read-autoplay')?.closest('label');
if (autoplay) {
autoplay.after(span);
} else {
controls.appendChild(span);
}
}
const formatted = `$${cost.toFixed(3)}`;
span.innerHTML = complete ? `<b>${formatted}</b>` : `${formatted} (${percent}%)`;
}
/**
* Binds the voice preview button of the speech settings. Clicking it plays
* "Hi, I'm [voice name], ready to read. For example; [pangram]" through the
* TTS endpoint with the settings currently shown, picking a random pangram
* each time; clicking again while the preview loads or plays cancels it.
* @param {HTMLElement} btn - The preview button.
* @param {Function} settings_getter - Returns the TTS settings currently
* displayed: { tts_model, voice,
* voice_name }.
*/
export function bind_voice_preview(btn, settings_getter) {
preview_btn = btn;
preview_get_settings = settings_getter;
btn.onclick = () => {
if (preview_state === 'idle') start_preview();
else stop_reading();
};
}
/**
* Starts the voice preview: stops any running readback or preview first,
* speaks the greeting with a random pangram and returns to idle when it ends.
*/
async function start_preview() {
stop_reading();
const token = ++preview_token;
const settings = typeof preview_get_settings === 'function' ? (preview_get_settings() || {}) : {};
const pangram = PREVIEW_PANGRAMS[Math.floor(Math.random() * PREVIEW_PANGRAMS.length)];
const text = `Hi, I'm ${settings.voice_name || 'TinAI'}, ready to read. For example; ${pangram}`;
set_preview_state('loading');
await play_streamed(text, '', settings, 'Preview Failed', () => set_preview_state('playing'));
if (token === preview_token) {
set_preview_state('idle');
}
}
/**
* Updates the preview playback state and refreshes the preview button.
* @param {string} next - New state: idle, loading, or playing.
*/
function set_preview_state(next) {
preview_state = next;
sync_preview_ui();
}
/**
* Refreshes the preview button: dimmed with a cancel hint while the preview
* audio loads or plays, normal otherwise.
*/
function sync_preview_ui() {
if (!preview_btn) return;
const active = preview_state !== 'idle';
preview_btn.classList.toggle('btn-preview-playing', active);
preview_btn.title = active ? 'Cancel preview' : 'Preview voice';
}
read-aloud.js
×
Type: Web, text/x-java
33.84 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-java
33.84 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File