import { GAP_THRESHOLD } from "./utilities/constants.js"
import { generateId, parseCSV, parseTranscriptJSON, mergeSegmentsToSentences, farthestColor } from './utilities/tools.js';
import { loadAudioFile, decodeSampleArrayBuffer } from "./utilities/audio.js";
/**
* Holds transcript data and pre-computes paragraph and speaker-block groupings
* from the flat segment list for efficient rendering.
* @property {object[]} segments - Flat list of transcript segments ({start, end, speaker, text}).
* @property {object[]} paragraphs - Contiguous runs of segments sharing the same speaker, split on long gaps.
* @property {object[]} speakerBlocks - Contiguous runs of paragraphs sharing the same speaker.
* @property {string} sourceFilename - Original filename of the transcript source.
*/
export class Transcript {
segments = [];
paragraphs = []; // larger chunks of segments where the speaker remains constant or no breaks
speakerBlocks = [];
sourceFilename = "";
/**
* @param {object[]} segments - Flat list of transcript segments ({start, end, speaker, text}).
*/
constructor(segments) {
this.segments = segments;
this.buildTranscript();
}
/**
* @param {boolean} [deep=false] - If true, returns a deep copy (segments are cloned); otherwise shallow.
* @returns {Transcript}
*/
clone(deep = false) {
const segments = deep ? this.segments.map(s => ({ ...s })) : this.segments;
return new Transcript(segments);
}
/**
* Separates transcript segments into paragraphs a speaker blocks ahead of time for easy
* transcript management.
*/
buildTranscript() {
// Build paragraphs
const paragraphs = this.#buildParagraphs(this.segments);
this.paragraphs = paragraphs;
// build speaker blocks
const blocks = this.#buildSpeakerBlocks(this.paragraphs);
this.speakerBlocks = blocks;
}
/**
* Separates transcript segments into paragraphs
* @param {object[]} segments - flat list of transcript segment objects
* @returns {object[]} array of paragraph objects, each with speaker and segments
*/
#buildParagraphs(segments) {
let paragraphs = [];
let paragraph = null;
segments.forEach((segment, index) => {
// attempt to get previous segment
const prev = index > 0 ? segments[index - 1] : null;
// manualParaBreak: true = force break, false = force merge, undefined = use gap logic
let paraBreak;
if (segment.manualParaBreak === true) {
paraBreak = true;
} else if (segment.manualParaBreak === false) {
paraBreak = false;
} else {
paraBreak = prev != null && (segment.start - prev.end > GAP_THRESHOLD);
}
// if the speaker changes, or there is a paragraph break, end the paragraph
if (!paragraph || segment.speaker !== paragraph.speaker || paraBreak) {
paragraph = {
speaker: segment.speaker,
segments: [segment]
};
paragraphs.push(paragraph);
// otherwise, just add a new segment to the paragraph
} else {
paragraph.segments.push(segment);
}
});
return paragraphs;
}
/**
* Separates transcript paragraphs in speaker blocks
* @param {object[]} paragraphs - array of paragraph objects from #buildParagraphs
* @returns {object[]} array of speaker block objects, each with speaker and paragraphs
*/
#buildSpeakerBlocks(paragraphs) {
// if there are no paragraphs, return empty list
if (!paragraphs.length) {
return [];
}
const blocks = [];
let currentSpeaker = paragraphs[0].speaker;
let currentBlock = {speaker: currentSpeaker, paragraphs: []};
for (let i = 0; i < paragraphs.length; i++) {
const paragraph = paragraphs[i];
// if the speaker changes, push the block and create a new one
if (paragraph.speaker !== currentSpeaker) {
// push the old block
blocks.push(currentBlock);
// create new block with the new speaker and new paragraph
currentBlock = {
speaker: paragraph.speaker,
paragraphs: [paragraph]
}
// update the current speaker
currentSpeaker = paragraph.speaker;
// otherwise, just add it to the current block
} else {
currentBlock.paragraphs.push(paragraph);
}
}
// make sure the last block was pushed
blocks.push(currentBlock);
return blocks;
}
/**
* Serializes segments to a CSV string.
* Speaker ids are used as-is; fields with commas, quotes, or newlines are RFC 4180 escaped.
* @returns {string}
*/
compileCSV() {
const header = 'start,end,speaker,text';
const escape = s => /[\x22,\n]/.test(s) ? `"${s.replace(/\x22/g, '\x22\x22')}"` : s;
const rows = this.segments.map(s =>
`${s.start},${s.end},${escape(s.speaker)},${escape(s.text)}`
);
return [header, ...rows].join('\n');
}
/**
* Serializes loadedSegments to CSV and triggers a browser file download.
* Speaker ids are replaced with their display names so renames are preserved.
* Fields containing commas, quotes, or newlines are RFC 4180 escaped.
* Clears the dirty flag after saving.
*/
saveTranscriptCSV() {
// Serialize segments back to CSV (start, end, speaker, text)
// Use display name for speaker so renames are preserved
const header = 'start,end,speaker,text';
// RFC 4180 CSV escaping: wrap in quotes if value contains comma, quote, or newline
const escape = s => /[\x22,\n]/.test(s) ? `"${s.replace(/\x22/g, '\x22\x22')}"` : s;
const rows = this.transcript().segments.map(s =>
`${s.start},${s.end},${escape(this.speakerDisplayName(s.speaker))},${escape(s.text)}`
);
const csv = [header, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
// Append "_edited" suffix to avoid overwriting the original file
a.download = this.transcript().sourceFilename.replace(/\.csv$/i, '') + '_edited.csv';
a.click();
URL.revokeObjectURL(a.href);
this.markTranscriptDirty(false);
}
/**
* Replaces the segment at segmentIndex with two new segments and rebuilds groupings.
* @param {number} segmentIndex - index of the segment to replace
* @param {object} newSegmentA - first replacement segment (earlier portion)
* @param {object} newSegmentB - second replacement segment (later portion)
*/
splitSegment(segmentIndex, newSegmentA, newSegmentB) {
this.segments.splice(segmentIndex, 1, newSegmentA, newSegmentB);
this.buildTranscript();
}
/**
* Merges two adjacent segments into one and rebuilds groupings.
* @param {number} segmentIndexA - index of the earlier segment
* @param {number} segmentIndexB - index of the later segment
*/
mergeSegments(segmentIndexA, segmentIndexB) {
const segments = this.segments;
const a = segments[segmentIndexA];
const b = segments[segmentIndexB];
if (a.speaker !== b.speaker) return;
// create the new segment, preserving A's paragraph-break flag
const mergedWords = (a.words?.length && b.words?.length)
? [...a.words, ...b.words]
: null;
const newSegment = {
start: a.start,
end: b.end,
speaker: a.speaker,
text: (a.text + ' ' + b.text).trim(),
words: mergedWords,
}
if (a.manualParaBreak !== undefined) newSegment.manualParaBreak = a.manualParaBreak;
// splice the new segment at the position of segment A, replacing both segments A and B
segments.splice(segmentIndexA, 2, newSegment);
this.buildTranscript();
}
/**
* Reassigns a segment to a different speaker, marks the transcript dirty,
* and fully re-renders the transcript and regions.
* @param {number} segIdx - index into loadedSegments
* @param {string} newSpeakerId - id of the speaker to assign to the segment
*/
changeSpeaker(segIdx, newSpeakerId) {
if (segIdx < 0 || segIdx >= this.segments.length) return;
this.segments[segIdx].speaker = newSpeakerId;
this.buildTranscript();
}
/**
* Reassigns all segments in a paragraph to a different speaker and rebuilds groupings.
* @param {object} paragraph - paragraph object (with .segments array)
* @param {string} newSpeakerId - id of the speaker to assign
*/
changeParagraphSpeaker(paragraph, newSpeakerId) {
paragraph.segments.forEach(segment => { segment.speaker = newSpeakerId; });
this.buildTranscript();
}
/**
* Returns the index of the segment that contains the given time, or null if none.
* @param {number} time - time in seconds
* @returns {number|null}
*/
segmentAtTime(time) {
if (!this.segments.length) {
return -1;
}
for (let i = 0; i < this.segments.length; i++) {
const seg = this.segments[i];
if (seg.start <= time && time <= seg.end) {
return i;
}
}
return null;
}
/**
* Returns the index of the paragraph that contains the segment at segIdx.
* @param {number} segIdx - index into this.segments
* @returns {number} paragraph index, or -1 if not found
*/
#paragraphIndexForSegment(segIdx) {
const seg = this.segments[segIdx];
return this.paragraphs.findIndex(p => p.segments.includes(seg));
}
/**
* Forces a paragraph break before the segment at segIdx by setting manualParaBreak = true.
* Returns the previous flag value so the caller can undo.
* @param {number} segIdx - index of the segment that will start the new paragraph
* @returns {boolean|undefined|null} previous manualParaBreak value, or null if segIdx is invalid
*/
splitParagraphAt(segIdx) {
if (segIdx <= 0 || segIdx >= this.segments.length) return null;
const seg = this.segments[segIdx];
const prevFlag = seg.manualParaBreak;
seg.manualParaBreak = true;
this.buildTranscript();
return prevFlag;
}
/**
* Merges the paragraph containing segIdx with the previous paragraph.
* Only works when the two paragraphs share the same speaker.
* Returns {segIdx, prevFlag} for undo, or null if the merge is not possible.
* @param {number} segIdx - index of any segment in the paragraph to merge upward
* @returns {{segIdx: number, prevFlag: boolean|undefined}|null}
*/
mergeParagraphBefore(segIdx) {
const paraIdx = this.#paragraphIndexForSegment(segIdx);
if (paraIdx <= 0) return null;
const para = this.paragraphs[paraIdx];
const prevPara = this.paragraphs[paraIdx - 1];
if (para.speaker !== prevPara.speaker) return null;
const firstSeg = para.segments[0];
const firstSegIdx = this.segments.indexOf(firstSeg);
const prevFlag = firstSeg.manualParaBreak;
firstSeg.manualParaBreak = false;
this.buildTranscript();
return { segIdx: firstSegIdx, prevFlag };
}
/**
* Restores a manualParaBreak flag for undo/redo.
* @param {number} segIdx - index into this.segments
* @param {boolean|undefined} flag - the flag value to restore; undefined removes the flag
*/
restoreParaBreak(segIdx, flag) {
const seg = this.segments[segIdx];
if (flag === undefined) {
delete seg.manualParaBreak;
} else {
seg.manualParaBreak = flag;
}
this.buildTranscript();
}
/**
* Serializes the transcript to the canonical paragraph > sentence > word JSON format.
* Uses the pre-computed paragraphs array so manual paragraph breaks are preserved.
* @returns {{paragraphs: object[]}}
*/
compileJSON() {
return {
paragraphs: this.paragraphs.map(para => ({
speaker: para.speaker,
start: para.segments[0].start,
end: para.segments[para.segments.length - 1].end,
sentences: para.segments.map(seg => {
const s = { start: seg.start, end: seg.end, text: seg.text, words: seg.words ?? [] };
if (seg.wordsStale) s.wordsStale = true;
return s;
})
}))
};
}
}
/**
* Container class for waveform audio data and metadata.
* @property {string} url - Streaming URL, blob URL, or object URL for the audio.
* @property {number} sampleRate - Sample rate of the audio in Hz; -1 if unknown.
* @property {number} duration - Duration of the audio in seconds; -1 if unknown.
* @property {number[]|null} peaks - Pre-computed amplitude peaks for fast waveform rendering.
* @property {string} filename - Original filename of the audio source.
* @property {File|null} file - Original File reference for large audio (chunked path); null for small files or server audio.
*/
export class Waveform {
url = ""; // Streaming URL, blob URL, or object URL for the audio
sampleRate = -1;
duration = -1; // Duration of the audio in seconds
peaks = null; // Pre-computed peaks for fast waveform rendering
filename = ""; // Original filename of the audio source
file = null; // Original File reference (large local audio only) — use directly for upload to avoid re-read
hasAudioMp3 = false; // Whether a converted audio.mp3 copy exists on the server
/**
* @param {object} [options] - waveform data options
* @param {string} [options.url=""] - Streaming URL, blob URL, or object URL for the audio.
* @param {number} [options.sampleRate=-1] - Sample rate in Hz; -1 if unknown.
* @param {number} [options.duration=-1] - Duration in seconds; -1 if unknown.
* @param {number[]|null} [options.peaks=null] - Pre-computed amplitude peaks for waveform rendering.
* @param {string} [options.filename=""] - Original filename of the audio source.
* @param {File|null} [options.file=null] - Original File reference for direct upload (large files only).
* @param {boolean} [options.hasAudioMp3=false] - Whether a converted audio.mp3 copy exists on the server.
*/
constructor({ url = "", sampleRate = -1, duration = -1, peaks = null, filename = "", file = null, hasAudioMp3 = false } = {}) {
this.url = url;
this.sampleRate = sampleRate;
this.duration = duration;
this.peaks = peaks;
this.filename = filename;
this.file = file;
this.hasAudioMp3 = hasAudioMp3;
}
/**
* @param {boolean} [deep=false] - If true, peaks arrays are cloned; otherwise copied by reference.
* @returns {Waveform}
*/
clone(deep = false) {
return new Waveform({
url: this.url,
sampleRate: this.sampleRate,
duration: this.duration,
peaks: deep && this.peaks ? this.peaks.map(ch => ch.slice()) : this.peaks,
filename: this.filename,
hasAudioMp3: this.hasAudioMp3,
});
}
}
/**
* Container class for defined speakers in a transcript.
* @property {string} id - Unique identifier (raw UID) for the speaker.
* @property {string} name - Display name shown in the UI.
* @property {string} hue - Hex color string used to visually distinguish this speaker.
* @property {{audioBuffer: AudioBuffer, blobUrl: string, peaks: number[]}}} sample - Voice sample assigned to the speaker.
*/
export class Speaker {
id = null; // The raw UID of the speaker
name = null; // The display name of the speaker
hue = null; // The display color of the speaker
// {audioBuffer, blobUrl, peaks}
sample = {}; // The audio sample assigned to the speaker
/**
* @param {string} id - Unique identifier (raw UID) for the speaker.
* @param {string} name - Display name shown in the UI.
* @param {string} hue - Hex color string used to visually distinguish this speaker.
* @param {{audioBuffer: AudioBuffer, blobUrl: string, peaks: number[]}} [sample={}] - Voice sample assigned to the speaker.
*/
constructor(id, name, hue, sample = {}) {
this.id = id;
this.name = name;
this.hue = hue;
this.sample = sample;
}
/**
* @param {boolean} [deep=false] - If true, peaks in sample are cloned; otherwise sample is copied by reference.
* @returns {Speaker}
*/
clone(deep = false) {
const sample = deep && this.sample?.peaks
? { ...this.sample, peaks: this.sample.peaks.slice() }
: this.sample;
return new Speaker(this.id, this.name, this.hue, sample);
}
}
/**
* Container class for data owned by a project. Allows for having differing server and local copies.
* @property {Transcript|null} transcript - The transcript object for this project.
* @property {Waveform|null} waveform - Waveform object containing url, sampleRate, duration, and peaks.
* @property {Object.<string, Speaker>} speakers - Dictionary of Speaker objects keyed by speaker id.
*/
export class ProjectData {
transcript = null;
waveform = null; // A Waveform object containing url, sampleRate, duration, and peaks
speakers = {}; // A dictionary of Speaker class objects with speaker ids as the key
/**
* @param {boolean} [deep=false] - If true, returns a deep copy; otherwise shallow (waveform and speakers copied by reference).
* @returns {ProjectData}
*/
clone(deep = false) {
let clone = new ProjectData();
if (this.transcript) {
clone.transcript = this.transcript.clone(deep);
}
if (this.waveform) {
clone.waveform = this.waveform.clone(deep);
}
if (deep) {
clone.speakers = {};
for (const [id, spk] of Object.entries(this.speakers)) {
clone.speakers[id] = spk.clone(deep);
}
} else {
clone.speakers = this.speakers;
}
return clone;
}
}
/**
* Represents a single transcription project. Stores both a server-side and
* a local working copy of the project data (transcript, speakers, waveform),
* tracks dirty state per data category, and fires callbacks when data changes.
* @property {string} projectId - UUID of the project; assigned by the server on first push.
* @property {string} projectName - Human-readable name of the project.
* @property {string} createdDate - ISO 8601 timestamp of when the project was created.
* @property {string} modifiedDate - ISO 8601 timestamp of the last modification.
* @property {boolean} synced - True if the local copy is up to date with the server.
* @property {boolean} localOnly - True if the project has never been pushed to the server.
* @property {boolean} speakersDirty - True if speaker data has unsaved local changes.
* @property {boolean} transcriptDirty - True if transcript data has unsaved local changes.
* @property {boolean} waveformDirty - True if waveform data has unsaved local changes.
* @property {boolean} hasTranscript - True if a transcript has been loaded.
* @property {boolean} hasSpeakers - True if speakers have been loaded.
* @property {boolean} hasWaveform - True if a waveform has been loaded.
* @property {ProjectData} local - Local working copy of the project data.
* @property {ProjectData} server - Server-authoritative copy of the project data.
* @property {callback} onStateChange - Callback fired whenever any state changes.
* @property {callback} onSpeakersModified - Callback fired when speakers are marked dirty.
* @property {callback} onTranscriptModified - Callback fired when the transcript is marked dirty.
* @property {callback} onWaveformModified - Callback fired when the waveform is marked dirty.
* @property {callback} onSpeakersSaved - Callback fired when speakers are marked clean.
* @property {callback} onTranscriptSaved - Callback fired when the transcript is marked clean.
* @property {callback} onWaveformSaved - Callback fired when the waveform is marked clean.
*/
export class Project {
/**
* @param {string} [projectId=""] - UUID of the project; assigned by the server on first push.
* @param {string} [projectName=""] - Human-readable name of the project.
* @param {object} callbacks - callback functions for project state changes
* @param {callback} [callbacks.onStateChange] - Callback fired whenever any state changes.
* @param {Server} [callbacks.server=null] - Reference to the App Server instance.
*/
constructor(projectId = "", projectName = "", { onStateChange, server = null }) {
// this.dirty = false; // true, if the project has unsaved changes
this.synced = false; // true, if the project is up to date with the server
this.localOnly = true; // true, if the project has never been pushed to the server
this.speakersDirty = false;
this.waveformDirty = false;
this.transcriptDirty = false;
this.hasTranscript = false;
this.hasSpeakers = false;
this.hasWaveform = false;
// Current time as string
const now = new Date().toISOString();
// A project's uid will change when uploaded to the server for the first time
this.projectId = projectId; // UUID of the project. Will be generated on server project creation.
this.projectName = projectName;
this.createdDate = now;
this.modifiedDate = now;
this.local = new ProjectData(); // The local copy of the project data
this.server = new ProjectData(); // The server copy of the project data (truth)
this.annotations = { hyperlinks: {} }; // Annotation data (hyperlinks, etc.)
this.activeServer = server; // Reference to the App Server instance
this._onStateChange = onStateChange ?? (() => {});
this._onSpeakersModified = (() => {});
this._onTranscriptModified = (() => {});
this._onWaveformModified = (() => {});
this._onSpeakersSaved = (() => {});
this._onTranscriptSaved = (() => {});
this._onWaveformSaved = (() => {});
}
/**
* Registers callbacks that are invoked when each data category is marked dirty or clean.
* @param {object} callbacks - callback functions for data modification events
* @param {callback} [callbacks.onSpeakersModified] - called when speakers are marked dirty
* @param {callback} [callbacks.onTranscriptModified] - called when the transcript is marked dirty
* @param {callback} [callbacks.onWaveformModified] - called when the waveform is marked dirty
* @param {callback} [callbacks.onSpeakersSaved] - called when speakers are marked clean
* @param {callback} [callbacks.onTranscriptSaved] - called when the transcript is marked clean
* @param {callback} [callbacks.onWaveformSaved] - called when the waveform is marked clean
*/
registerModifyCallbacks({onSpeakersModified, onTranscriptModified, onWaveformModified,
onSpeakersSaved, onTranscriptSaved, onWaveformSaved}) {
this._onSpeakersModified = onSpeakersModified ?? (() => {});
this._onTranscriptModified = onTranscriptModified ?? (() => {});
this._onWaveformModified = onWaveformModified ?? (() => {});
this._onSpeakersSaved = onSpeakersSaved ?? (() => {});
this._onTranscriptSaved = onTranscriptSaved ?? (() => {});
this._onWaveformSaved = onWaveformSaved ?? (() => {});
}
/**
* Loads a local audio file, extracts metadata and peaks, and stores the
* results in the local ProjectData. The returned object URL is owned by this
* project; revoke it (URL.revokeObjectURL(this.local.waveformUrl)) when the
* project is closed or replaced.
* @param {File} file - audio file to load; any format supported by the browser
* @param {function(number):void} [onProgress] - progress callback, fraction 0–1 (large files only)
*/
async loadLocalAudio(file, onProgress) {
const { url, file: audioFile, sampleRate, peaks, duration, filename } = await loadAudioFile(file, { onProgress });
this.local.waveform = new Waveform({ url, file: audioFile ?? null, sampleRate, duration, peaks, filename });
this.hasWaveform = true;
this.markWaveformDirty();
}
/**
* Pulls data from the server and stores it in memory
*/
async pullFromServer() {
// ----- WAVEFORM LOAD ----- //
// get the waveform streaming url and metadata (including pre-computed peaks)
const wfMeta = await this.activeServer.getWaveform(this.projectId).catch(() => null);
if (wfMeta && Object.keys(wfMeta).length > 0) {
const peaks = wfMeta.peaks?.length ? [new Float32Array(wfMeta.peaks)] : null;
this.server.waveform = new Waveform({
url: this.activeServer.audioUrl(this.projectId),
sampleRate: wfMeta.sampleRate ?? -1,
duration: wfMeta.duration ?? -1,
filename: wfMeta.filename ?? '',
hasAudioMp3: wfMeta.hasAudioMp3 ?? false,
peaks,
});
this.hasWaveform = true;
}
// ----- SPEAKERS LOAD ----- //
// Fetch saved speaker data
const data = await this.activeServer.getSpeakers(this.projectId);
if (Object.keys(data).length > 0) {
await Promise.all(Object.values(data).map(async (speaker) => {
let sample = {};
if (speaker.has_sample) {
try {
const arrayBuffer = await this.activeServer.getSpeakerSample(this.projectId, speaker.id);
sample = await decodeSampleArrayBuffer(arrayBuffer);
} catch(e) {
console.warn(`Could not load sample for speaker ${speaker.id}:`, e);
}
}
// Use local=false so these end up in server copy, preserved by the clone below
this.addSpeaker(speaker.id, speaker.name, speaker.hue, sample, false);
}));
this.hasSpeakers = true;
}
// ----- TRANSCRIPT LOAD ----- //
// if there is an associated transcript, get it
if (this.hasTranscript) {
let transcriptResult = null;
try {
transcriptResult = await this.activeServer.getTranscript(this.projectId);
} catch(e) {
console.warn('Could not load transcript from server:', e);
}
if (transcriptResult) {
if (transcriptResult.contentType?.includes('application/json')) {
this.loadTranscriptJSON(JSON.parse(transcriptResult.text), false);
} else {
this.loadTranscriptCSV(transcriptResult.text, false);
}
}
}
// ----- ANNOTATIONS LOAD ----- //
try {
const annotations = await this.activeServer.getAnnotations(this.projectId);
if (annotations) this.annotations = annotations;
} catch(e) {
// annotations.json may not exist for older projects — that's fine
}
// Clone the server data to the local working copy
this.local = this.server.clone(true);
}
/**
* Parses a CSV string and loads the resulting segments into the project's transcript.
* @param {string} transcript_text - CSV text with columns: start, end, speaker, text
* @param {boolean} [local=true] - if true, loads into the local copy; otherwise the server copy
*/
loadTranscriptCSV(transcript_text, local = true) {
let projectData = null;
if(local){
projectData = this.local;
} else {
projectData = this.server;
}
let segments = mergeSegmentsToSentences(parseCSV(transcript_text));
// assign any missing speakers
const uniqueSpeakers = [...new Set(segments.map(segment => segment.speaker))];
uniqueSpeakers.forEach(speakerId => {
// If there is no registered speaker with this ID
if (!projectData.speakers.hasOwnProperty(speakerId)) {
this.addSpeaker(speakerId, speakerId, null, {}, local);
}
});
projectData.transcript = new Transcript([...segments]);
this.hasSpeakers = true;
this.hasTranscript = true;
this.markTranscriptDirty();
this.markSpeakersDirty();
}
/**
* Parses a transcript JSON object and loads the resulting segments into the project's transcript.
* Flattens the paragraph > sentence > word hierarchy into a flat segments list.
* @param {object} json - transcript JSON with a `paragraphs` array
* @param {boolean} [local=true] - if true, loads into the local copy; otherwise the server copy
*/
loadTranscriptJSON(json, local = true) {
const projectData = local ? this.local : this.server;
const segments = parseTranscriptJSON(json);
const uniqueSpeakers = [...new Set(segments.map(s => s.speaker))];
uniqueSpeakers.forEach(speakerId => {
if (!projectData.speakers.hasOwnProperty(speakerId)) {
this.addSpeaker(speakerId, speakerId, null, {}, local);
}
});
projectData.transcript = new Transcript([...segments]);
this.hasSpeakers = true;
this.hasTranscript = true;
this.markTranscriptDirty();
this.markSpeakersDirty();
}
/**
* Adds a new speaker to the project. Auto-assigns a hue if none is provided.
* @param {string} id - speaker id
* @param {string} name - display name
* @param {string|null} [hue=null] - hex color; auto-generated if null
* @param {object} [sample={}] - voice sample data
* @param {boolean} [local=true] - if true, adds to the local copy; else server copy
*/
addSpeaker(id, name, hue = null, sample = {}, local = true) {
// If there is no assigned hue, generate a new one
if (!hue) {
let colors = []
if (local) {
colors = Object.values(this.local.speakers).map(speaker => speaker.hue);
} else {
colors = Object.values(this.server.speakers).map(speaker => speaker.hue);
}
hue = farthestColor(colors);
}
const newSpeaker = new Speaker(id, name, hue, sample);
if (local) {
this.local.speakers[id] = newSpeaker;
} else {
this.server.speakers[id] = newSpeaker;
}
this.hasSpeakers = true;
this.markSpeakersDirty();
}
/**
* Removes a speaker from the active speakers dict and marks speakers dirty.
* @param {string} speakerId - the id of the speaker to remove
*/
removeSpeaker(speakerId) {
delete this.speakers()[speakerId];
this.markSpeakersDirty();
}
/**
* Reassigns all transcript segments from one speaker to another.
* @param {string} speakerId - id of the speaker to reassign from
* @param {string} targetId - id of the speaker to assign to
*/
reassignSegments(speakerId, targetId) {
// Reassign all segments
this.transcript().segments.forEach(s => {
if (s.speaker === speakerId) {
s.speaker = targetId;
}
});
this.transcript().buildTranscript();
this.markTranscriptDirty();
}
/**
* Marks all dirty flags as clean after a successful server upload.
* Also snapshots the current local state as the new save baseline so
* revertToLastSave() can restore it later.
* Call App.pushProjectToServer() to perform the actual upload.
*/
markClean() {
this.server = this.local.clone(true);
this.markAllDirty(false);
}
/**
* Discards all local changes since the last save by restoring the local
* working copy from the saved server snapshot, then re-renders all panels.
*/
revertToLastSave() {
const waveformWasDirty = this.waveformDirty;
this.local = this.server.clone(true);
// Fire modified callbacks to trigger panel re-renders
this._onSpeakersModified();
this._onTranscriptModified();
if (waveformWasDirty) this._onWaveformModified();
// Clear all dirty flags without firing re-render again
this.speakersDirty = false;
this.transcriptDirty = false;
this.waveformDirty = false;
this._onSpeakersSaved();
this._onTranscriptSaved();
this._onWaveformSaved();
this._onStateChange();
}
/**
* Creates a new Project that is a copy of this one with a different id and name.
* @param {string} newId - unique identifier for the new copy
* @param {string} newName - display name for the new copy
* @param {object} callbacks - callback functions for the new project
* @param {callback} callbacks.onStateChange - fired whenever state changes in the copy
* @returns {Project}
*/
createCopy(newId, newName, { onStateChange }) {
const copy = new Project(newId, newName, { onStateChange, server: this.activeServer });
copy.local = this.local.clone(true);
return copy;
}
/**
* Updates the project name and notifies listeners via the state-change callback.
* @param {string} name - the new display name for the project
*/
setName(name) {
this.projectName = name;
this._onStateChange();
}
// ----- TRANSCRIPT FUNCTIONS ----- //
/**
* Merges two adjacent segments and marks the transcript dirty.
* @param {number} segmentIndexA - index of the earlier segment
* @param {number} segmentIndexB - index of the later segment
*/
mergeSegments(segmentIndexA, segmentIndexB) {
this.transcript().mergeSegments(segmentIndexA, segmentIndexB);
// Remap annotation segmentIdx values: links on segmentIndexB merge into
// segmentIndexA (dropped), links above segmentIndexB shift down by one.
this.#remapAnnotationIndices((idx) => {
if (idx === segmentIndexB) return null; // segment removed — drop link
if (idx > segmentIndexB) return idx - 1;
return idx;
});
this.markTranscriptDirty();
}
/**
* Splits a segment into two and marks the transcript dirty.
* @param {number} segmentIndex - index of the segment to split
* @param {object} newSegmentA - first replacement segment (earlier portion)
* @param {object} newSegmentB - second replacement segment (later portion)
*/
splitSegment(segmentIndex, newSegmentA, newSegmentB) {
this.transcript().splitSegment(segmentIndex, newSegmentA, newSegmentB);
// Remap annotation segmentIdx values: links above the split point shift
// up by one. Links on the split segment itself are dropped because the
// original character offsets are no longer valid after the split.
this.#remapAnnotationIndices((idx) => {
if (idx === segmentIndex) return null; // offsets invalidated — drop link
if (idx > segmentIndex) return idx + 1;
return idx;
});
this.markTranscriptDirty();
}
/**
* Applies a remapping function to every hyperlink's segmentIdx.
* If the function returns null the link is deleted; otherwise its
* segmentIdx is updated to the returned value.
* @param {function(number): number|null} remap - receives the current segmentIdx; return the new index or null to delete the link
*/
#remapAnnotationIndices(remap) {
const hyperlinks = this.annotations?.hyperlinks;
if (!hyperlinks) return;
for (const [id, link] of Object.entries(hyperlinks)) {
const next = remap(link.segmentIdx);
if (next === null) delete hyperlinks[id];
else link.segmentIdx = next;
}
}
/**
* Reassigns a segment to a different speaker, marks the transcript dirty,
* and fully re-renders the transcript and regions.
* @param {number} segIdx - index into loadedSegments
* @param {string} newSpeakerId - id of the speaker to assign the segment to
*/
changeSpeaker(segIdx, newSpeakerId) {
this.transcript().changeSpeaker(segIdx, newSpeakerId);
this.markTranscriptDirty();
}
/**
* Reassigns all segments in a paragraph to a different speaker, marks the transcript dirty.
* @param {object} paragraph - paragraph object from the transcript
* @param {string} newSpeakerId - id of the speaker to assign
*/
changeParagraphSpeaker(paragraph, newSpeakerId) {
this.transcript().changeParagraphSpeaker(paragraph, newSpeakerId);
this.markTranscriptDirty();
}
/**
* Forces a paragraph break before the segment at segIdx.
* @param {number} segIdx - index of the segment that will start the new paragraph
* @returns {boolean|undefined|null} previous manualParaBreak value for undo
*/
splitParagraphAt(segIdx) {
const prevFlag = this.transcript().splitParagraphAt(segIdx);
if (prevFlag !== null) this.markTranscriptDirty();
return prevFlag;
}
/**
* Merges the paragraph containing segIdx with the previous same-speaker paragraph.
* @param {number} segIdx - index of any segment in the paragraph to merge upward
* @returns {{segIdx: number, prevFlag: boolean|undefined}|null} undo info, or null if not possible
*/
mergeParagraphBefore(segIdx) {
const result = this.transcript().mergeParagraphBefore(segIdx);
if (result) this.markTranscriptDirty();
return result;
}
// ----- HELPERS ----- //
/**
* Returns the index of the local transcript segment at the given time.
* @param {number} time - seconds
* @returns {number|null}
*/
segmentAtTime(time) {
return this.local.transcript.segmentAtTime(time);
}
/**
* Gets the general project metadata as a dictionary (version, id, name, dates, speakers).
* Waveform details live in waveformMetadata() instead.
* @returns {object} metadata dictionary with version, id, name, created, modified, and speakers
*/
metadata() {
const speakersDict = this.speakers();
const speakersMeta = {};
for (const [id, spk] of Object.entries(speakersDict)) {
speakersMeta[id] = {
id: spk.id,
name: spk.name,
hue: spk.hue,
has_sample: !!(spk.sample && spk.sample.blobUrl),
};
}
return {
version: '1.0',
id: this.projectId,
name: this.projectName,
created: this.createdDate,
modified: this.modifiedDate,
speakers: speakersMeta,
};
}
/**
* Gets all waveform data — sampleRate, duration, filename, and peaks — for server upload.
* peaks is stored locally as Float32Array[] (one per channel from the Web Audio API).
* This converts the mono channel (index 0) to a plain Array so JSON.stringify produces
* a compact JSON number array instead of a keyed object.
* @returns {{sampleRate: number, duration: number, filename: string, peaks: number[]}} waveform metadata object
*/
waveformMetadata() {
const waveform = this.waveform();
const peakChannel = waveform?.peaks?.[0]; // mono mix is always channel 0
return {
sampleRate: waveform?.sampleRate ?? -1,
duration: waveform?.duration ?? -1,
filename: waveform?.filename ?? '',
peaks: peakChannel ? Array.from(peakChannel) : [],
};
}
/**
* Gets the speaker object for the speaker with the requested id
* @param {string} speakerId - The ID of the speaker to get
* @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
* @returns {Speaker|undefined} the speaker object, or undefined if not found
*/
getSpeaker(speakerId, local = true) {
return this.speakers(local)[speakerId]
}
/**
* Helper function to get the transcript from the project data
* @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
* @returns {Transcript|null} the transcript object
*/
transcript(local = true) {
if(local) {
return this.local.transcript;
}
return this.server.transcript;
}
/**
* Helper function to get the speakers from the project data
* @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
* @returns {Object.<string, Speaker>} dictionary of Speaker objects keyed by speaker id
*/
speakers(local = true) {
if(local) {
return this.local.speakers;
}
return this.server.speakers;
}
/**
* Helper function to get the waveform from the project data
* @param {bool} local - True, if the function should get a local copy, false if it should get a server copy
* @returns {Waveform|null} the waveform object, or null if none loaded
*/
waveform(local = true) {
return local ? this.local.waveform : this.server.waveform;
}
/**
* Returns whether any project sector is dirty
* @returns {boolean} true if any sector has unsaved changes
*/
isDirty() {
return this.speakersDirty || this.transcriptDirty || this.waveformDirty;
}
/**
* Sets the dirty (or edited) status of all sectors
* @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
*/
markAllDirty(dirty = true) {
this.markSpeakersDirty(dirty);
this.markTranscriptDirty(dirty);
this.markWaveformDirty(dirty);
}
/**
* Sets the dirty (or edited) status of the speakers sector
* @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
*/
markSpeakersDirty(dirty = true) {
this.speakersDirty = dirty;
// If it is being changed, call the callback
if(dirty) {
this._onSpeakersModified();
this._onStateChange();
} else {
this._onSpeakersSaved();
this._onStateChange();
}
}
/**
* Sets the dirty (or edited) status of the transcript sector
* @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
*/
markTranscriptDirty(dirty = true) {
this.transcriptDirty = dirty;
// If it is being changed, call the callback
if(dirty) {
this._onTranscriptModified();
this._onStateChange();
} else {
this._onTranscriptSaved();
this._onStateChange();
}
}
/**
* Sets the dirty (or edited) status of the waveform sector
* @param {boolean} [dirty=true] - true to mark dirty, false to mark clean
*/
markWaveformDirty(dirty = true) {
this.waveformDirty = dirty;
// If it is being changed, call the callback
if(dirty) {
this._onWaveformModified();
this._onStateChange();
} else {
this._onWaveformSaved();
this._onStateChange();
}
}
/**
* Packages the project into a downloadable ZIP archive containing:
* - project.json — all project, waveform, and speaker metadata
* - peaks.json — waveform amplitude peaks
* - transcript.csv — recompiled transcript segments
* - <filename>.wav — the main audio file
* - samples/<id>.wav — speaker audio samples
* @param {FileSystemFileHandle} filepath - file handle to write the archive to
* @returns {Promise<void>}
*/
async packageProject(filepath) {
const zip = new window.JSZip();
const waveform = this.waveform();
const speakersDict = this.speakers();
// --- project.json ---
const speakersMeta = {};
for (const [id, spk] of Object.entries(speakersDict)) {
speakersMeta[id] = {
id: spk.id,
name: spk.name,
hue: spk.hue,
has_sample: !!(spk.sample && spk.sample.blobUrl),
};
}
// If saving a server project locally, give it a new local id. Otherwise it can keep its id.
let projId = this.projectId;
if (!this.localOnly) {
projId = generateId()
}
const projectMeta = {
version: '1.0',
id: projId,
name: this.projectName,
created: this.createdDate,
modified: this.modifiedDate,
waveform: {
sampleRate: waveform?.sampleRate ?? -1,
duration: waveform?.duration ?? -1,
filename: waveform?.filename ?? '',
},
speakers: speakersMeta,
};
zip.file('project.json', JSON.stringify(projectMeta, null, 2));
// --- peaks.json ---
const peaks = waveform?.peaks ?? [];
zip.file('peaks.json', JSON.stringify(peaks));
// --- transcript.csv ---
if (this.hasTranscript) {
const csv = this.transcript().compileCSV();
zip.file('transcript.csv', csv);
}
// --- speaker audio samples ---
const samplesFolder = zip.folder('samples');
for (const [id, spk] of Object.entries(speakersDict)) {
if (spk.sample?.blobUrl) {
try {
const resp = await fetch(spk.sample.blobUrl);
const buf = await resp.arrayBuffer();
samplesFolder.file(`${id}.wav`, buf);
} catch (e) {
console.warn(`Could not bundle sample for speaker ${id}:`, e);
}
}
}
// --- waveform audio ---
if (waveform?.url) {
try {
const resp = await fetch(waveform.url);
const buf = await resp.arrayBuffer();
const filename = waveform.filename || 'audio.wav';
zip.file(filename, buf);
} catch (e) {
console.warn('Could not bundle waveform audio:', e);
}
}
// --- Generate and save ---
// Use default compression level as no meaningful difference between default and 9
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
if (!(filepath && typeof filepath.createWritable === 'function')) {
filepath = await window.showSaveFilePicker({
suggestedName: `${this.projectName.replace(/[^a-z0-9_\-]/gi, '_')}.wfs`,
types: [{ description: 'Transcriber Project', accept: { 'application/octet-stream': ['.wfs'] } }],
});
}
const writable = await filepath.createWritable();
await writable.write(blob);
await writable.close();
}
/**
* Factory function — the inverse of Project.packageProject().
* Reads a packaged .wfs ZIP file (File, Blob, or ArrayBuffer) and reconstructs
* a fully-populated Project from its contents.
* @param {File|Blob|ArrayBuffer} zipFile - The packaged project archive.
* @param {object} [callbacks] - callback functions for the restored project
* @param {callback} [callbacks.onStateChange] - fired whenever state changes in the restored project
* @returns {Promise<Project>}
*/
static async unpackageProject(zipFile, { onStateChange } = {}) {
const zip = await window.JSZip.loadAsync(zipFile);
// --- project.json ---
const projectMeta = JSON.parse(await zip.file('project.json').async('string'));
const project = new Project(projectMeta.id, projectMeta.name, { onStateChange: onStateChange ?? (() => {}) });
project.createdDate = projectMeta.created;
project.modifiedDate = projectMeta.modified;
// --- peaks.json ---
const peaks = JSON.parse(await zip.file('peaks.json').async('string'));
// --- waveform audio ---
const waveformMeta = projectMeta.waveform;
const audioFilename = waveformMeta.filename || 'audio.wav';
let waveformUrl = '';
const audioEntry = zip.file(audioFilename);
if (audioEntry) {
const audioBuf = await audioEntry.async('arraybuffer');
waveformUrl = URL.createObjectURL(new Blob([audioBuf], { type: 'audio/wav' }));
}
project.local.waveform = new Waveform({
url: waveformUrl,
sampleRate: waveformMeta.sampleRate,
duration: waveformMeta.duration,
peaks,
filename: audioFilename,
});
project.hasWaveform = !!(waveformUrl || peaks.length);
// --- speakers ---
for (const [id, spkMeta] of Object.entries(projectMeta.speakers)) {
let sample = {};
if (spkMeta.has_sample) {
const sampleEntry = zip.file(`samples/${id}.wav`);
if (sampleEntry) {
const sampleBuf = await sampleEntry.async('arraybuffer');
sample = await decodeSampleArrayBuffer(sampleBuf);
}
}
project.addSpeaker(id, spkMeta.name, spkMeta.hue, sample);
}
// --- transcript.csv ---
const transcriptEntry = zip.file('transcript.csv');
if (transcriptEntry) {
const csvText = await transcriptEntry.async('string');
project.loadTranscriptCSV(csvText);
}
return project;
}
}