import { formatTimeMs, hexToHue } from "../utilities/tools.js"
import { HuePicker } from "../components/hue_picker.js"
import { ConfirmDialog } from "../components/confirm_dialog.js"
import { encodeMonoWav } from "../utilities/audio.js"
/**
* Panel that lists all speakers in the active project, allowing the user to
* rename them, change their color, attach a voice sample, and delete them.
* Communicates back to the workspace via onSpeakerHover and onSpeakerModified callbacks.
*/
export class SpeakersPanel {
/**
* @param {object} workspace - the Workspace controller instance
* @param {object} callbacks - callback functions for speaker interactions
* @param {function} callbacks.onSpeakerHover - called with speaker id when a speaker is hovered
* @param {function} callbacks.onSpeakerModified - called with speaker when a speaker is modified
*/
constructor(workspace, { onSpeakerHover, onSpeakerModified }) {
this.workspace = workspace;
this.onSpeakerHover = onSpeakerHover ?? (() => {})
this.onSpeakerModified = onSpeakerModified ?? (() => {})
this.currentColorIndex = 0;
this.hoveredSpeakerId = null;
this.activeHuePicker = null;
// Active recording state — at most one recording session at a time
this.activeRecorder = null; // { id, stream, recorder, chunks, stopFn }
this.activeProject = null;
this.#getElements();
this.#setupListeners();
}
/** Attaches click handler to the "Add Speaker" button. */
#setupListeners() {
this.addSpeakerBtn.addEventListener('click', () => { this.addSpeaker(); });
}
/** Binds panel DOM elements to instance properties. */
#getElements() {
this.root = document.getElementById('speakersPanel');
this.addSpeakerBtn = this.root.querySelector('#addSpeakerBtn');
this.speakersEmpty = this.root.querySelector('#speakersEmpty');
this.speakersTable = this.root.querySelector('#speakersTable');
this.speakersTableBody = this.root.querySelector('#speakersTableBody');
}
/** Clears the panel by re-rendering with no active project. */
clearSpeakersPanel() {
this.renderSpeakersPanel();
}
/**
* Loads speaker data from the given project and renders the panel.
* @param {Project} project - the project to load speaker data from
*/
loadFromProject(project) {
this.activeProject = project;
this.renderSpeakersPanel()
}
/**
* Adds a new speaker with an auto-generated unique id (SPEAKER_N where N
* avoids collisions). Immediately focuses the new name span so the user can
* type a custom name right away.
* @param {string|null} [speakerId=null] - explicit id to assign; auto-generated if null
*/
addSpeaker(speakerId = null) {
let newId = speakerId;
// if an ID is passed, just use it. Otherwise, generate a new id.
if (!newId) {
// Find the next N such that SPEAKER_N doesn't already exist
let n = Object.keys(this.activeProject.speakers()).length + 1;
newId = `SPEAKER_${String(n).padStart(2, '0')}`
// while (this.activeProject.getSpeaker(speakerId)) {
// newId = `SPEAKER_${++n}`;
// }
}
this.activeProject.addSpeaker(newId, newId);
this.activeProject.markSpeakersDirty();
const created = this.activeProject.speakers()[newId];
this.workspace.history.push({
label: 'Add speaker', dirtyFlags: ['speakers'],
undo: () => { delete this.activeProject.speakers()[newId]; },
redo: () => { this.activeProject.speakers()[newId] = created; },
});
this.workspace._updateUndoRedoButtons();
// Focus the new speaker's name span for immediate rename
requestAnimationFrame(() => {
const spans = this.root.querySelectorAll('.speaker-name');
const last = spans[spans.length - 1];
if (last) {
last.click();
}
});
}
/** Closes and removes the active hue picker if one is open. */
closeHuePicker() {
if (this.activeHuePicker) { this.activeHuePicker.remove(); this.activeHuePicker = null; }
}
/**
* Opens a hue picker popover anchored below `swatchEl`.
* @param {object} speaker - the speaker object whose hue should be changed
* @param {HTMLElement} swatchEl - the color swatch that was clicked
*/
openHuePicker(speaker, swatchEl) {
this.closeHuePicker();
const oldHue = speaker.hue;
this.activeHuePicker = new HuePicker({
onSelect: (newColor) => {
speaker.hue = newColor;
document.querySelectorAll(`.speaker-name[data-speaker-id="${speaker.id}"]`).forEach(el => {
el.style.color = newColor;
});
swatchEl.style.background = newColor;
this.onSpeakerModified(speaker);
this.workspace.history.push({
label: 'Change speaker color', dirtyFlags: ['speakers'],
undo: () => { speaker.hue = oldHue; },
redo: () => { speaker.hue = newColor; },
});
this.workspace._updateUndoRedoButtons();
},
onCancel: () => {
// nothing extra needed on cancel; picker removes itself
},
});
this.activeHuePicker.open(swatchEl, hexToHue(speaker.hue));
}
// ── Speaker voice samples ─────────────────────────────────────────────────
/**
* Decodes an audio ArrayBuffer and extracts 120 peak bars for the mini
* waveform visualization.
* @param {ArrayBuffer} arrayBuffer - raw audio data to decode
* @returns {Promise<{audioBuffer: AudioBuffer, peaks: number[]}>}
*/
async decodeSampleBuffer(arrayBuffer) {
const actx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await actx.decodeAudioData(arrayBuffer);
// Build peaks from the first channel only (samples are typically mono)
const data = audioBuffer.getChannelData(0);
const numBars = 120;
const blockSize = Math.floor(data.length / numBars);
const peaks = [];
for (let i = 0; i < numBars; i++) {
let max = 0;
for (let j = 0; j < blockSize; j++) max = Math.max(max, Math.abs(data[i * blockSize + j]));
peaks.push(max);
}
actx.close();
return { audioBuffer, peaks };
}
/**
* Stores a decoded speaker sample in state. Revokes any previously stored
* blob URL to avoid memory leaks, then re-renders the speakers panel.
* @param {string} id - speaker id
* @param {AudioBuffer} audioBuffer - the decoded audio buffer
* @param {string|null} blobUrl - blob URL for re-upload to server, or null if not needed
* @param {number[]} peaks - 120-bar peak array for waveform display
*/
setSpeakerSample(id, audioBuffer, blobUrl, peaks) {
const speaker = this.activeProject.speakers()[id];
if (speaker.sample?.blobUrl) {
URL.revokeObjectURL(speaker.sample.blobUrl);
}
speaker.sample = { audioBuffer, blobUrl, peaks };
this.activeProject.markSpeakersDirty();
}
/**
* Removes a speaker's voice sample and revokes its blob URL.
* @param {string} id - speaker id
*/
removeSpeakerSample(id) {
const speaker = this.activeProject.speakers()[id];
if (speaker.sample?.blobUrl) {
URL.revokeObjectURL(speaker.sample.blobUrl);
}
delete speaker.sample;
this.activeProject.markSpeakersDirty();
}
/**
* Renders waveform peak bars into a canvas element.
* Bars to the left of `progress` are drawn at full opacity (played),
* bars to the right at reduced opacity (unplayed).
* @param {HTMLCanvasElement} canvas - target canvas element to draw into
* @param {number[]} peaks - array of 0..1 amplitude values
* @param {number} [progress=0] - playback fraction 0..1
* @param {string} [color='#47c8ff'] - hex color for the bars
*/
drawSampleWaveform(canvas, peaks, progress = 0, color = '#47c8ff') {
const dpr = window.devicePixelRatio || 1;
const W = canvas.clientWidth || canvas.offsetWidth || 120;
const H = canvas.clientHeight || canvas.offsetHeight || 28;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
// Parse hex to RGB for rgba() fill
const cx = parseInt(color.replace('#',''), 16);
const r = (cx >> 16) & 255, g = (cx >> 8) & 255, b = cx & 255;
const mid = H / 2;
for (let x = 0; x < W; x++) {
const pi = Math.floor((x / W) * peaks.length);
const amp = Math.max(peaks[pi] * mid * 0.9, 0.5); // minimum visible height of 0.5px
const played = x / W < progress;
ctx.fillStyle = played ? `rgba(${r},${g},${b},0.9)` : `rgba(${r},${g},${b},0.35)`;
ctx.fillRect(x, mid - amp, 1, amp * 2);
}
}
/**
* Plays a speaker voice sample using the Web Audio API and animates a progress
* line over the mini waveform. Clicking the play button a second time stops
* playback early.
* @param {object} speaker - the speaker object whose sample will be played
* @param {HTMLButtonElement} playBtn - the play/stop button element
* @param {HTMLCanvasElement} canvas - waveform canvas to animate during playback
* @param {number[]} peaks - 120-bar peak array for waveform display
* @param {HTMLElement} progressLine - thin overlay element showing play position
*/
playSampleAudio(speaker, playBtn, canvas, peaks, progressLine) {
const sample = speaker.sample;
if (!sample) {
return;
}
const actx = new (window.AudioContext || window.webkitAudioContext)();
const src = actx.createBufferSource();
src.buffer = sample.audioBuffer;
src.connect(actx.destination);
const dur = sample.audioBuffer.duration;
const startTime = actx.currentTime;
playBtn.textContent = '⏹';
progressLine.style.display = 'block';
let raf;
const color = speaker.hue;
/** rAF loop: updates waveform shading and progress line position each frame */
const tick = () => {
const elapsed = actx.currentTime - startTime;
const frac = Math.min(elapsed / dur, 1);
this.drawSampleWaveform(canvas, peaks, frac, color);
const W = canvas.clientWidth || 120;
progressLine.style.left = (frac * W) + 'px';
if (frac < 1) { raf = requestAnimationFrame(tick); }
else { finish(); }
}
/** Cleanup: cancel rAF, reset button, hide progress line, close AudioContext */
const finish = () => {
cancelAnimationFrame(raf);
playBtn.textContent = '▶';
progressLine.style.display = 'none';
this.drawSampleWaveform(canvas, peaks, 0, color); // reset to unplayed state
actx.close();
}
src.onended = finish;
src.start();
raf = requestAnimationFrame(tick);
// Stop on second click
playBtn.onclick = () => {
src.stop();
finish();
playBtn.onclick = null;
};
}
/**
* Opens a hidden file input dialog for uploading a voice sample.
* On selection, decodes the audio and stores it via setSpeakerSample.
* @param {string} id - speaker id
*/
openSampleUpload(id) {
const input = document.createElement('input');
input.type = 'file'; input.accept = 'audio/*';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const arrayBuffer = await file.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: file.type });
const blobUrl = URL.createObjectURL(blob);
// Use a copy of the buffer (.slice(0)) because decodeAudioData detaches it
const { audioBuffer, peaks } = await this.decodeSampleBuffer(arrayBuffer.slice(0));
this.setSpeakerSample(id, audioBuffer, blobUrl, peaks);
};
input.click();
}
/**
* Starts microphone recording for a speaker voice sample (or stops if already
* recording). Auto-stops after 20 seconds. On stop, encodes to a Blob and
* stores via setSpeakerSample.
* @param {string} id - speaker id
* @param {HTMLButtonElement} btn - the ⏺ Rec button; its text is toggled
*/
startRecording(id, btn) {
if (this.activeRecorder) { this.activeRecorder.stopFn(); return; } // clicking again stops recording
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
const recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = e => chunks.push(e.data);
recorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop()); // release microphone
this.activeRecorder = null;
btn.textContent = '⏺ REC';
btn.classList.remove('rec-active');
const blob = new Blob(chunks, { type: 'audio/webm' });
const blobUrl = URL.createObjectURL(blob);
const arrayBuffer = await blob.arrayBuffer();
const { audioBuffer, peaks } = await this.decodeSampleBuffer(arrayBuffer);
this.setSpeakerSample(id, audioBuffer, blobUrl, peaks);
};
// Max 20s auto-stop
const timeout = setTimeout(() => recorder.stop(), 20000);
const stopFn = () => { clearTimeout(timeout); recorder.stop(); };
this.activeRecorder = { id, stream, recorder, chunks, stopFn };
recorder.start();
btn.textContent = '⏹ STOP';
btn.classList.add('rec-active');
}).catch(() => alert('Microphone access denied.'));
}
/**
* Opens the segment picker modal, showing all transcript segments.
* Clicking a row slices that segment's audio as the speaker's voice sample.
* If the selected segment belongs to a different speaker, a confirmation
* dialog is shown first.
* @param {object} speaker - the speaker object for whom the sample will be set
*/
openSegPicker(speaker) {
const wavesurferInstance = this.workspace.wavesurferInstance();
const totalDuration = this.activeProject.waveform().duration;;;
// Show all segments, not just this speaker's — lets user hear context too
const segs = this.activeProject.transcript().segments;
if (!segs.length) { alert('No transcript segments loaded.'); return; }
let pickerPlayRaf = null;
let pickerPlayBtn = null;
/** Stops any currently playing segment preview within the picker. */
const stopPickerPlay = () => {
if (pickerPlayBtn) {
pickerPlayBtn.textContent = '▶';
pickerPlayBtn.classList.remove('playing');
pickerPlayBtn = null;
}
if (pickerPlayRaf) {
cancelAnimationFrame(pickerPlayRaf);
pickerPlayRaf = null;
}
if (wavesurferInstance) {
wavesurferInstance.pause();
}
}
const overlay = document.createElement('div');
overlay.className = 'seg-picker-overlay';
const modal = document.createElement('div');
modal.className = 'seg-picker-modal';
const header = document.createElement('div');
header.className = 'seg-picker-header';
header.innerHTML = `<span>Select segment for <span style="color:${speaker.hue}">${speaker.name}</span></span>`;
const closeBtn = document.createElement('button');
closeBtn.className = 'seg-picker-close';
closeBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
closeBtn.onclick = () => { stopPickerPlay(); overlay.remove(); };
header.appendChild(closeBtn);
modal.appendChild(header);
const list = document.createElement('div');
list.className = 'seg-picker-list';
segs.forEach((seg, si) => {
const segSpeaker = this.activeProject.getSpeaker(seg.speaker);
const color = segSpeaker.hue;
const name = this.speakerDisplayName(seg.speaker);
const isSelf = seg.speaker === speaker.id; // dim non-matching speaker rows
const item = document.createElement('div');
item.className = 'seg-picker-item';
if (!isSelf) item.style.opacity = '0.55';
// Speaker dot
const dot = document.createElement('span');
dot.className = 'seg-picker-speaker-dot';
dot.style.background = color;
// Speaker name
const nameSpan = document.createElement('span');
nameSpan.className = 'seg-picker-speaker-name';
nameSpan.style.color = color;
nameSpan.textContent = name;
// Time
const timeSpan = document.createElement('span');
timeSpan.className = 'seg-picker-time';
timeSpan.textContent = formatTimeMs(seg.start) + ' – ' + formatTimeMs(seg.end);
// Text
const textSpan = document.createElement('span');
textSpan.className = 'seg-picker-text';
textSpan.textContent = seg.text;
// Play button — previews the segment in the main waveform
const playBtn = document.createElement('button');
playBtn.className = 'seg-picker-play';
playBtn.textContent = '▶';
playBtn.title = 'Preview segment';
playBtn.onclick = (e) => {
e.stopPropagation();
if (pickerPlayBtn === playBtn) {
stopPickerPlay();
return;
}
// toggle off
stopPickerPlay();
if (!wavesurferInstance || !totalDuration) {
return;
}
wavesurferInstance.seekTo(seg.start / totalDuration);
wavesurferInstance.play();
playBtn.textContent = '⏹';
playBtn.classList.add('playing');
pickerPlayBtn = playBtn;
// rAF loop to stop playback when the segment ends
/** Polls the wavesurfer position each frame and stops when the segment ends. */
function watchEnd() {
const t = wavesurferInstance.getCurrentTime();
if (t >= seg.end) {
stopPickerPlay();
return;
}
// if the segment has not reached its end, wait for the next frame
pickerPlayRaf = requestAnimationFrame(watchEnd);
}
pickerPlayRaf = requestAnimationFrame(watchEnd);
};
item.appendChild(dot);
item.appendChild(nameSpan);
item.appendChild(timeSpan);
item.appendChild(textSpan);
item.appendChild(playBtn);
// Click row (not play btn) to select segment as the voice sample
item.onclick = async () => {
if (!isSelf) {
// Confirm using a different speaker's segment
new ConfirmDialog(`This segment belongs to ${name}. Use it as a sample for ${this.speakerDisplayName(speaker.id)}?`, {
onConfirm: async () => {
stopPickerPlay();
overlay.remove();
await this.sliceSegmentAsSample(speaker.id, seg);
}
});
return;
}
stopPickerPlay();
overlay.remove();
await this.sliceSegmentAsSample(speaker.id, seg);
};
list.appendChild(item);
});
modal.appendChild(list);
overlay.appendChild(modal);
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) {
stopPickerPlay();
overlay.remove();
}
});
document.body.appendChild(overlay);
}
/**
* Extracts the audio for a single transcript segment from the main track and
* stores it as a speaker voice sample. Uses the full decoded audio buffer to
* avoid re-fetching; slices by sample index rather than time.
* Only channel 0 is used (mono sample).
* @param {string} id - speaker id
* @param {{start: number, end: number}} seg - segment with start/end in seconds
*/
async sliceSegmentAsSample(id, seg) {
const wavesurferInstance = this.workspace.wavesurferInstance();
const totalDuration = this.activeProject.waveform().duration;;;
if (!wavesurferInstance || !totalDuration) return;
// WaveSurfer doesn't expose the decoded buffer directly after the two-pass load,
// so we re-fetch the audio from the URL and decode it ourselves.
const wrapper = wavesurferInstance.getWrapper ? wavesurferInstance.getWrapper() : null;
const media = wrapper ? wrapper.closest('[data-wavesurfer]') : null;
// Try to get the audio element's src; fall back to currentStreamUrl
const audioEl = document.querySelector('#waveform audio, #waveform media') || wavesurferInstance.getMediaElement?.();
const loadedUrl = audioEl?.src || this.activeProject.waveform()?.url;
if (!loadedUrl) {
alert('Audio not loaded yet.');
return;
}
try {
const resp = await fetch(loadedUrl);
const fullBuf = await resp.arrayBuffer();
// Decode full audio then slice the channel data
const actx = new (window.AudioContext || window.webkitAudioContext)();
const fullAudio = await actx.decodeAudioData(fullBuf);
const sampleRate = fullAudio.sampleRate;
const startSample = Math.floor(seg.start * sampleRate);
const endSample = Math.min(Math.ceil(seg.end * sampleRate), fullAudio.length);
const segLen = endSample - startSample;
const slicedBuffer = actx.createBuffer(1, segLen, sampleRate);
const srcData = fullAudio.getChannelData(0);
slicedBuffer.copyToChannel(srcData.slice(startSample, endSample), 0);
// Build 120-bar peaks for the mini waveform
const numBars = 120;
const blockSize = Math.max(1, Math.floor(segLen / numBars));
const peaks = [];
const slicedData = slicedBuffer.getChannelData(0);
for (let i = 0; i < numBars; i++) {
let max = 0;
for (let j = 0; j < blockSize; j++) {
max = Math.max(max, Math.abs(slicedData[i * blockSize + j] || 0));
}
peaks.push(max);
}
// Re-render via OfflineAudioContext to ensure the buffer is fully rendered
// (not strictly necessary here but ensures compatibility)
const offCtx = new OfflineAudioContext(1, segLen, sampleRate);
const bufSrc = offCtx.createBufferSource();
bufSrc.buffer = slicedBuffer;
bufSrc.connect(offCtx.destination);
bufSrc.start();
await offCtx.startRendering();
actx.close();
// Encode the sliced buffer as a WAV blob so it can be uploaded to the server
const wavBlob = encodeMonoWav(slicedBuffer.getChannelData(0), slicedBuffer.sampleRate);
const blobUrl = URL.createObjectURL(wavBlob);
this.setSpeakerSample(id, slicedBuffer, blobUrl, peaks);
} catch(err) {
console.error('Slice error:', err);
alert('Could not extract segment audio.');
}
}
/**
* Builds and shows the speaker reassignment/deletion dialog.
* When affectedCount > 0 a dropdown lets the user choose a target speaker.
* @param {object} speaker - speaker being deleted
* @param {object} speakers - all speakers in the project
* @param {number} affectedCount - number of segments assigned to the speaker
* @param {object} callbacks - callback functions for dialog actions
* @param {object} callbacks.onConfirm - called with the target speaker id (or undefined)
*/
#buildReassignDialog(speaker, speakers, affectedCount, { onConfirm }) {
let warningText = "";
let confirmText = "";
let injection = null;
const headerText = `Delete speaker <span style="color:${speaker.hue}">${speaker.name}</span>`
const others = Object.keys(speakers).filter(id => id !== speaker.id);
if(affectedCount > 0) {
warningText = `${affectedCount} segment${affectedCount !== 1 ? 's are' : ' is'} assigned to this speaker. Reassign them to:`;
confirmText = 'Reassign & Delete'
// Speaker select dropdown
injection = document.createElement('select');
injection.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:0.4rem 0.6rem;outline:none;cursor:pointer;';
others.forEach(oid => {
const opt = document.createElement('option');
opt.value = oid;
opt.textContent = this.speakerDisplayName(oid);
injection.appendChild(opt);
});
} else {
warningText = `${speaker.name} has no assigned segments — safe to remove.`;
confirmText = 'Delete'
}
const reassignDialog = new ConfirmDialog(headerText, {
onConfirm: () => {
if (affectedCount > 0) { onConfirm(injection.value); }
else { onConfirm(); }
}
}, warningText, confirmText, "Cancel", injection);
// // Build reassignment dialog
// const others = Object.keys(speakers).filter(id => id !== speaker.id);
// const overlay = document.createElement('div');
// overlay.className = 'confirm-dialog-overlay';
//
// const modal = document.createElement('div');
// modal.className = 'confirm-dialog-modal';
// modal.style.cssText = 'max-height:none;width:380px;padding:0;';
//
// const header = document.createElement('div');
// header.className = 'confirm-dialog-header';
// header.innerHTML = `<span>Delete speaker <span style="color:${speaker.hue}">${name}</span></span>`;
// modal.appendChild(header);
//
// const body = document.createElement('div');
// body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';
//
// const warning = document.createElement('div');
// warning.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--text);line-height:1.6;';
// if(affectedCount > 0) {
// warning.textContent = `${affectedCount} segment${affectedCount !== 1 ? 's are' : ' is'} assigned to this speaker. Reassign them to:`;
// } else {
// warning.textContent = `${speaker.name} has no assigned segments — safe to remove.`;
// }
//
// body.appendChild(warning);
//
// let select = null;
// if (affectedCount > 0) {
// // Speaker select dropdown
// select = document.createElement('select');
// select.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:0.4rem 0.6rem;outline:none;cursor:pointer;';
// others.forEach(oid => {
// const opt = document.createElement('option');
// opt.value = oid;
// opt.textContent = this.speakerDisplayName(oid);
// select.appendChild(opt);
// });
// body.appendChild(select);
// }
// const actions = document.createElement('div');
// actions.style.cssText = 'display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.25rem;';
//
// const cancelBtn = document.createElement('button');
// cancelBtn.className = 'sample-btn';
// cancelBtn.textContent = 'Cancel';
// cancelBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
// cancelBtn.onclick = () => overlay.remove();
//
// const confirmBtn = document.createElement('button');
// confirmBtn.className = 'sample-btn';
// if(affectedCount > 0) {
// confirmBtn.textContent = 'Reassign & Delete'
// } else {
// confirmBtn.textContent = 'Delete'
// }
// confirmBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);border-color:rgba(255,119,119,0.4);color:#ff9999;';
// confirmBtn.onmouseenter = () => { confirmBtn.style.background = 'rgba(255,119,119,0.1)'; confirmBtn.style.borderColor = '#ff7777'; };
// confirmBtn.onmouseleave = () => { confirmBtn.style.background = ''; confirmBtn.style.borderColor = 'rgba(255,119,119,0.4)'; };
// confirmBtn.onclick = () => {
// if (select) {
// onConfirm(select.value);
// } else {
// onConfirm();
// }
// overlay.remove();
// };
// actions.appendChild(cancelBtn);
// actions.appendChild(confirmBtn);
// body.appendChild(actions);
// modal.appendChild(body);
// overlay.appendChild(modal);
// overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
// document.body.appendChild(overlay);
}
/**
* Deletes a speaker. If any transcript segments are assigned to this speaker,
* shows a reassignment modal prompting the user to pick another speaker before
* deletion. If no segments are affected, shows a simpler confirmation modal.
* Prevents deletion when only one speaker exists.
* @param {string} id - speaker id to remove
*/
deleteSpeaker(id) {
const speakers = this.activeProject.speakers();
if (Object.keys(speakers).length <= 1) return; // must keep at least one speaker
const speaker = speakers[id];
const segs = this.activeProject.transcript().segments;
const affectedIndices = segs.map((s, i) => s.speaker === id ? i : -1).filter(i => i >= 0);
const affectedCount = affectedIndices.length;
if (affectedCount > 0) {
this.#buildReassignDialog(speaker, speakers, affectedCount, {
onConfirm: (targetId) => {
const deletedSpeaker = { id: speaker.id, name: speaker.name, hue: speaker.hue, sample: speaker.sample };
this.activeProject.reassignSegments(speaker.id, targetId);
this.activeProject.removeSpeaker(speaker.id);
const transcript = this.activeProject.transcript();
this.workspace.history.push({
label: 'Delete speaker', dirtyFlags: ['speakers', 'transcript'],
undo: () => {
this.activeProject.speakers()[deletedSpeaker.id] = deletedSpeaker;
affectedIndices.forEach(i => { segs[i].speaker = deletedSpeaker.id; });
transcript.buildTranscript();
},
redo: () => {
affectedIndices.forEach(i => { segs[i].speaker = targetId; });
transcript.buildTranscript();
delete this.activeProject.speakers()[deletedSpeaker.id];
},
});
this.workspace._updateUndoRedoButtons();
}
});
} else {
this.#buildReassignDialog(speaker, speakers, 0, {
onConfirm: () => {
const deletedSpeaker = { id: speaker.id, name: speaker.name, hue: speaker.hue, sample: speaker.sample };
this.activeProject.removeSpeaker(speaker.id);
this.workspace.history.push({
label: 'Delete speaker', dirtyFlags: ['speakers'],
undo: () => {
this.activeProject.speakers()[deletedSpeaker.id] = deletedSpeaker;
},
redo: () => {
delete this.activeProject.speakers()[deletedSpeaker.id];
},
});
this.workspace._updateUndoRedoButtons();
}
});
}
}
/** Re-renders the speakers table from the active project's speaker data. */
renderSpeakersPanel() {
const readOnly = this.workspace.isReadOnly();
this.addSpeakerBtn.style.display = readOnly ? 'none' : '';
if (!this.activeProject || !Object.keys(this.activeProject.speakers()).length) {
this.speakersEmpty.style.display = 'flex';
this.speakersTable.style.display = 'none';
return;
}
// If there are speakers, disable the "empty" element and enable the table
this.speakersEmpty.style.display = 'none';
this.speakersTable.style.display = 'table';
// Clear the table
this.speakersTableBody.innerHTML = '';
// build a speaker row for each speaker, ordered by first appearance in
// the transcript (if one exists) or alphabetically by speaker id otherwise
const speakers = Object.values(this.activeProject.speakers());
const segments = this.activeProject.transcript()?.segments;
if (segments && segments.length) {
const firstIndex = {};
segments.forEach((seg, i) => {
if (!(seg.speaker in firstIndex)) firstIndex[seg.speaker] = i;
});
speakers.sort((a, b) => {
const ia = firstIndex[a.id] ?? Infinity;
const ib = firstIndex[b.id] ?? Infinity;
if (ia !== ib) return ia - ib;
return a.id.localeCompare(b.id);
});
} else {
speakers.sort((a, b) => a.id.localeCompare(b.id));
}
speakers.forEach((speaker) => this.buildSpeakerRow(speaker));
}
/**
* Builds a table row for a speaker and appends it to the speakers table body.
* @param {object} speaker - speaker object with id, name, hue, and sample
*/
buildSpeakerRow(speaker) {
const tr = document.createElement('tr');
// Hue display and selection cell
const tdColor = document.createElement('td');
tdColor.style.width = '2.5rem';
const swatch = document.createElement('span');
swatch.className = 'speaker-row-swatch';
swatch.style.background = speaker.hue;
if (!this.workspace.isReadOnly()) {
swatch.title = 'Change color';
swatch.addEventListener('click', (e) => {
e.stopPropagation();
this.openHuePicker(speaker, swatch);
});
}
tdColor.appendChild(swatch);
tr.appendChild(tdColor);
// Name cell — editable
const tdName = document.createElement('td');
const nameSpan = this.makeSpeakerNameSpan(speaker)
tdName.appendChild(nameSpan);
tr.appendChild(tdName);
// Raw ID cell
const tdId = document.createElement('td');
tdId.style.color = 'var(--muted)';
tdId.textContent = speaker.id;
tr.appendChild(tdId);
// Sample cell
const tdSample = this.buildSpeakerSampleCell(speaker);
tr.appendChild(tdSample);
// Delete cell — hidden in read-only mode
const tdDel = document.createElement('td');
tdDel.style.width = '1.5rem';
if (!this.workspace.isReadOnly()) {
const delBtn = document.createElement('button');
delBtn.className = 'speaker-del-btn';
delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
delBtn.title = 'Delete speaker';
delBtn.disabled = Object.keys(this.activeProject.speakers()).length <= 1;
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteSpeaker(speaker.id);
});
tdDel.appendChild(delBtn);
}
tr.appendChild(tdDel);
this.speakersTableBody.appendChild(tr);
}
/**
* Creates a styled, clickable speaker name span. Clicking it enters inline
* edit mode; hovering it highlights that speaker's regions on the waveform.
* @param {object|string} speaker - the speaker object, or a speaker id string to look up
* @returns {HTMLElement}
*/
makeSpeakerNameSpan(speaker) {
if (!speaker) {
console.error("Speaker cannot be null.");
return;
}
// if speaker is passed as an id, get the speaker by its id
if (typeof speaker === 'string') {
speaker = this.activeProject.speakers()[speaker];
}
const span = document.createElement('span');
span.className = 'speaker-name';
span.dataset.speakerId = speaker.id;
span.style.color = speaker.hue;
span.textContent = speaker.name;
if (!this.workspace.isReadOnly()) {
span.title = 'Click to rename';
span.addEventListener('click', () => this.makeSpeakerEditable(span, speaker));
}
span.addEventListener('mouseenter', () => this.onSpeakerHover(speaker.id));
span.addEventListener('mouseleave', () => this.onSpeakerHover(-1));
return span;
}
/**
* Builds the sample cell for a speaker row. If a sample exists it shows
* the waveform with play/remove controls; otherwise it shows upload/record/segment buttons.
* @param {object} speaker - the speaker object to build the cell for
* @returns {HTMLTableCellElement}
*/
buildSpeakerSampleCell(speaker) {
const tdSample = document.createElement('td');
tdSample.className = 'sample-cell';
const sample = speaker.sample;
const readOnly = this.workspace.isReadOnly();
if (sample && Object.keys(speaker.sample).length) {
// Show waveform + play button + remove (remove hidden in read-only)
const wrap = document.createElement('div');
wrap.className = 'sample-waveform-wrap';
const playBtn = document.createElement('button');
playBtn.className = 'sample-play-btn';
playBtn.textContent = '▶';
playBtn.title = 'Play sample';
const canvasWrap = document.createElement('div');
canvasWrap.className = 'sample-canvas-wrap';
const canvas = document.createElement('canvas');
const progressLine = document.createElement('div');
progressLine.className = 'sample-progress-line';
canvasWrap.appendChild(canvas);
canvasWrap.appendChild(progressLine);
wrap.appendChild(playBtn);
wrap.appendChild(canvasWrap);
if (!readOnly) {
const removeBtn = document.createElement('button');
removeBtn.className = 'sample-remove-btn';
removeBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
removeBtn.title = 'Remove sample';
removeBtn.onclick = () => this.removeSpeakerSample(speaker.id);
wrap.appendChild(removeBtn);
}
tdSample.appendChild(wrap);
// Draw waveform after layout
requestAnimationFrame(() => {
this.drawSampleWaveform(canvas, sample.peaks, 0, speaker.hue);
});
playBtn.onclick = () => this.playSampleAudio(speaker, playBtn, canvas, sample.peaks, progressLine);
} else if (!readOnly) {
// Show add options only when not read-only
const empty = document.createElement('div');
empty.className = 'sample-empty';
const uploadBtn = document.createElement('button');
uploadBtn.className = 'sample-btn';
uploadBtn.innerHTML = '<span class="icon icon-arrow-up" style="width:11px;height:11px;"></span> Upload';
uploadBtn.onclick = () => this.openSampleUpload(speaker.id);
const recBtn = document.createElement('button');
recBtn.className = 'sample-btn';
recBtn.textContent = '⏺ Rec';
recBtn.onclick = () => this.startRecording(speaker.id, recBtn);
const segBtn = document.createElement('button');
segBtn.className = 'sample-btn';
segBtn.textContent = '✂ Segment';
segBtn.title = 'Use audio from a transcript segment';
segBtn.onclick = () => this.openSegPicker(speaker);
empty.appendChild(uploadBtn);
empty.appendChild(recBtn);
empty.appendChild(segBtn);
tdSample.appendChild(empty);
}
return tdSample;
}
/**
* Returns the user-facing display name for a speaker, falling back to the
* raw CSV id if no rename has been applied.
* @param {string} id - speaker id to look up
* @returns {string}
*/
speakerDisplayName(id) {
return this.activeProject.getSpeaker(id).name || id;
}
/**
* Renames a speaker: updates liveNameMap, marks the transcript dirty, and
* live-patches all in-DOM speaker name spans so the page doesn't need a full
* re-render.
* @param {object} speaker - the speaker object to rename
* @param {string} newName - desired display name; reset to id if blank
*/
renameSpeaker(speaker, newName) {
newName = newName.trim();
// If no new name is provided, set it to the di
if (!newName) {
newName = speaker.id;
}
const oldName = speaker.name;
// Set the new name
speaker.name = newName;
// Set the project to be dirty
this.activeProject.markSpeakersDirty();
// Update all in-DOM speaker name spans without re-rendering the whole transcript
document.querySelectorAll(`.speaker-name[data-speaker-id="${speaker.id}"]`).forEach(el => {
el.textContent = newName;
});
if (oldName !== newName) {
this.workspace.history.push({
label: 'Rename speaker', dirtyFlags: ['speakers'],
undo: () => { speaker.name = oldName; },
redo: () => { speaker.name = newName; },
});
this.workspace._updateUndoRedoButtons();
}
this.renderSpeakersPanel();
}
/**
* Replaces a speaker name span with a text input for inline editing.
* Commits on Enter or blur; cancels (restores span) on Escape.
* @param {HTMLElement} spanEl - the .speaker-name span to replace
* @param {object} speaker - the speaker object being renamed
*/
makeSpeakerEditable(spanEl, speaker) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'speaker-name-input';
input.value = speaker.name;
input.style.color = speaker.hue;
spanEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
this.renameSpeaker(speaker, input.value);
// Re-create the span in place of the input
input.replaceWith(this.makeSpeakerNameSpan(speaker));
}
const revert = () => {
input.replaceWith(spanEl);
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
} else if (e.key === 'Escape') {
revert();
}
e.stopPropagation();
});
input.addEventListener('blur', commit);
}
}