components_transcribe_dialog.js
import { WHISPER_MODELS, PYANNOTE_MODELS, estimateCost, estimateTime, fmtCost, fmtDur } from '../utilities/transcription_pricing.js';
/**
* Options dialog shown before starting a Modal transcription job.
* Displays audio stats, model selectors, speaker options, and a live cost estimate.
*/
export class TranscribeDialog {
/**
* @param {object} options - configuration for the dialog
* @param {number} options.audioDuration - audio duration in seconds (-1 if unknown)
* @param {boolean} options.hasVoiceSamples - whether any speaker has a voice sample attached
* @param {string[]|null} [options.allowedModels] - whisper model keys allowed by the user's plan;
* null means no restriction (show all)
* @param {function} options.onConfirm - called with transcription options when confirmed
* @param {function} [options.onDismiss] - called when the dialog is cancelled
*/
constructor({ audioDuration, hasVoiceSamples, allowedModels = null, onConfirm, onDismiss }) {
this._audioDuration = audioDuration;
this._hasVoiceSamples = hasVoiceSamples;
this._allowedModels = allowedModels;
this._onConfirm = onConfirm ?? (() => {});
this._onDismiss = onDismiss ?? (() => {});
this.#build();
}
/** Builds and appends the dialog overlay to the document body. */
#build() {
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
const modal = document.createElement('div');
modal.className = 'confirm-dialog-modal transcribe-dialog-modal';
// ── Header ────────────────────────────────────────────────────────────
const header = document.createElement('div');
header.className = 'confirm-dialog-header';
header.innerHTML = '<span>◎ Transcribe Audio</span>';
modal.appendChild(header);
// ── Body ──────────────────────────────────────────────────────────────
const body = document.createElement('div');
body.className = 'transcribe-dialog-body';
// Audio stats
body.appendChild(this.#buildStats());
// Options grid
const grid = document.createElement('div');
grid.className = 'transcribe-dialog-grid';
const availableWhisperModels = this._allowedModels
? WHISPER_MODELS.filter(m => this._allowedModels.includes(m.key))
: WHISPER_MODELS;
const defaultWhisper = availableWhisperModels.some(m => m.key === 'medium')
? 'medium'
: availableWhisperModels.at(-1)?.key ?? 'medium';
grid.appendChild(this.#makeSelectRow('Whisper model', 'td-whisper',
availableWhisperModels.map(m => ({ value: m.key, label: m.label })), defaultWhisper));
grid.appendChild(this.#makeSelectRow('Diarization model', 'td-pyannote',
PYANNOTE_MODELS.map(m => ({ value: m.key, label: m.label })), PYANNOTE_MODELS[0].key));
grid.appendChild(this.#makeSpeakerCountRow());
grid.appendChild(this.#makeVoiceSamplesRow());
body.appendChild(grid);
// Cost estimate
body.appendChild(this.#buildEstimate());
// Actions
body.appendChild(this.#buildActions(overlay, modal));
modal.appendChild(body);
overlay.appendChild(modal);
// Dismiss on backdrop click (only when mousedown also started on the overlay)
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) { overlay.remove(); this._onDismiss(); }
});
// Wire live estimate updates
const update = () => this.#updateEstimate(modal);
modal.querySelector('#td-whisper').addEventListener('change', update);
modal.querySelector('#td-pyannote').addEventListener('change', update);
this.#updateEstimate(modal);
document.body.appendChild(overlay);
}
/** @returns {HTMLElement} audio stats section showing duration. */
#buildStats() {
const section = document.createElement('div');
section.className = 'transcribe-dialog-stats';
const dur = this._audioDuration > 0 ? fmtDur(this._audioDuration) : '—';
section.innerHTML = `
<div class="transcribe-stat">
<span class="transcribe-stat-label">audio duration</span>
<span class="transcribe-stat-value">${dur}</span>
</div>`;
return section;
}
/** @returns {HTMLElement} cost/time estimate section with placeholder spans. */
#buildEstimate() {
const section = document.createElement('div');
section.className = 'transcribe-dialog-estimate';
section.innerHTML = `
<div class="transcribe-estimate-row">
<span class="transcribe-estimate-label">est. cost</span>
<span class="transcribe-estimate-value" id="td-est-cost">—</span>
</div>
<div class="transcribe-estimate-row">
<span class="transcribe-estimate-label">est. time</span>
<span class="transcribe-estimate-value" id="td-est-time">—</span>
</div>
<div class="transcribe-estimate-note">Estimates assume A10G GPU. Actual costs vary.</div>`;
return section;
}
/**
* @param {HTMLElement} overlay - the backdrop element to remove on action
* @param {HTMLElement} modal - the modal element used to read form values
* @returns {HTMLElement} actions row with Cancel and Confirm buttons
*/
#buildActions(overlay, modal) {
const actions = document.createElement('div');
actions.className = 'transcribe-dialog-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'sample-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => { overlay.remove(); this._onDismiss(); });
actions.appendChild(cancelBtn);
const confirmBtn = document.createElement('button');
confirmBtn.className = 'sample-btn transcribe-dialog-confirm-btn';
confirmBtn.textContent = '◎ Start Transcription';
confirmBtn.addEventListener('click', () => {
overlay.remove();
const speakerRaw = parseInt(modal.querySelector('#td-speaker-count').value, 10);
this._onConfirm({
modelSize: modal.querySelector('#td-whisper').value,
pyannoteModel: modal.querySelector('#td-pyannote').value,
speakerCount: speakerRaw > 0 ? speakerRaw : null,
includeVoiceSamples: modal.querySelector('#td-voice-samples').checked,
});
});
actions.appendChild(confirmBtn);
return actions;
}
/**
* @param {string} labelText - visible label for the row
* @param {string} id - id attribute for the select element
* @param {Array<{value: string, label: string}>} options - select options
* @param {string} defaultValue - value to pre-select
* @returns {HTMLElement} a label+select row element
*/
#makeSelectRow(labelText, id, options, defaultValue) {
const row = document.createElement('div');
row.className = 'transcribe-dialog-row';
const opts = options.map(o =>
`<option value="${o.value}"${o.value === defaultValue ? ' selected' : ''}>${o.label}</option>`
).join('');
row.innerHTML = `
<label class="transcribe-dialog-label" for="${id}">${labelText}</label>
<select id="${id}" class="transcribe-dialog-select">${opts}</select>`;
return row;
}
/** @returns {HTMLElement} row with a numeric input for estimated speaker count. */
#makeSpeakerCountRow() {
const row = document.createElement('div');
row.className = 'transcribe-dialog-row';
row.innerHTML = `
<label class="transcribe-dialog-label" for="td-speaker-count">Est. speakers</label>
<div class="transcribe-dialog-control">
<input type="number" id="td-speaker-count" class="transcribe-dialog-number-input"
min="0" max="50" step="1" value="0" />
<span class="transcribe-dialog-hint">0 = auto-detect</span>
</div>`;
return row;
}
/** @returns {HTMLElement} row with a checkbox to include recorded voice samples. */
#makeVoiceSamplesRow() {
const row = document.createElement('div');
row.className = 'transcribe-dialog-row';
const disabled = !this._hasVoiceSamples;
const hint = disabled ? 'No speaker samples recorded' : 'Guide diarization using recorded samples';
row.innerHTML = `
<label class="transcribe-dialog-label" for="td-voice-samples">Voice samples</label>
<div class="transcribe-dialog-control">
<input type="checkbox" id="td-voice-samples" ${disabled ? 'disabled' : ''} />
<span class="transcribe-dialog-hint">${hint}</span>
</div>`;
return row;
}
/**
* Reads the current model selections and updates the cost/time estimate display.
* @param {HTMLElement} modal - the modal element containing the form inputs
*/
#updateEstimate(modal) {
const wModel = modal.querySelector('#td-whisper').value;
const pModel = modal.querySelector('#td-pyannote').value;
const dur = this._audioDuration;
if (dur > 0) {
modal.querySelector('#td-est-cost').textContent = fmtCost(estimateCost(dur, wModel, pModel));
modal.querySelector('#td-est-time').textContent = fmtDur(estimateTime(dur, wModel, pModel));
}
}
}