audio-tracker
Version:
A headless JavaScript library that gives you full control over web audio — playback, tracking, and Media Session integration made simple.
332 lines • 13.6 kB
JavaScript
/**
* Timestamp module for AudioTracker that tracks audio segments and subsegments in real time.
* Fires callbacks on segment, subsegment, and speaker changes based on playback time.
*
* @param tracker - AudioTracker instance with timestamp options
* @returns Cleanup function that unsubscribes from events
*
* @example
* // Example timestamp structure passed during AudioTracker construction:
* const timestampData = {
* segments: [
* {
* id: 'seg1',
* start: 0,
* end: 30,
* order: 1,
* speaker: { id: 'sp1', name: 'Speaker 1' },
* label: 'Intro',
* text: 'hi everyone'
* subSegments: [
* { id: 'sub1', start: 0, end: 10, order: 1, text: 'hi' },
* { id: 'sub2', start: 10, end: 30, order: 2, text: 'everyone' },
* ],
* },
* {
* id: 'seg2',
* start: 30,
* end: 60,
* order: 2,
* label: 'Main section',
* text: 'welcome to todays podcast...'
* },
* ],
* gapBehavior: 'persist-previous', // Optional gap behavior
* };
*
* const tracker = new AudioTracker('audio.mp3', { timestamp: timestampData });
* tracker.use(timestampModule);
*/
export function timestampModule(tracker) {
// Normalize segments from options, ensuring order and subSegments are set
const segments = Array.isArray(tracker.options?.timestamp?.segments)
? tracker.options.timestamp.segments.map((seg, index) => ({
...seg,
subSegments: Array.isArray(seg.subSegments) ? seg.subSegments : [],
}))
: [];
const gapBehavior = tracker.options?.timestamp?.gapBehavior || null;
if (!segments.length) {
console.warn("TimestampModule: No segments provided or array is empty.");
return () => { };
}
// Removed runtime type checks for seg.start and seg.end because TS guarantees their type
const validatedSegments = segments
.filter((seg) => {
if (seg.start >= seg.end) {
console.warn("TimestampModule: Segment start >= end", seg);
return false;
}
return true;
})
.sort((a, b) => a.start - b.start);
if (validatedSegments.length === 0) {
console.warn("TimestampModule: No valid segments after validation.");
return () => { };
}
let currentSegmentIndex = -1;
let currentSubSegmentIndex = -1;
let currentSpeaker = null;
let lastValidSegmentIndex = -1;
let lastReportedSegment = null;
let lastReportedSubSegment = null;
let isInitialized = false;
// Removed typeof and isNaN checks on 'time' parameter; TS type system covers this
function findSegmentIndexByTime(time) {
return validatedSegments.findIndex((seg) => time >= seg.start && time < seg.end);
}
function findSubSegmentIndexByTime(segment, time) {
if (!segment.subSegments?.length)
return -1;
return segment.subSegments.findIndex((sub) => time >= sub.start && time < sub.end);
}
function findPreviousSegmentByTime(time) {
for (let i = validatedSegments.length - 1; i >= 0; i--) {
if (validatedSegments[i].end <= time)
return i;
}
return -1;
}
function findNextSegmentByTime(time) {
for (let i = 0; i < validatedSegments.length; i++) {
if (validatedSegments[i].start >= time)
return i;
}
return -1;
}
function getSegmentWithGapBehavior(segmentIndex, time) {
if (segmentIndex !== -1)
return validatedSegments[segmentIndex];
if (gapBehavior === "persist-previous") {
const prevIndex = findPreviousSegmentByTime(time);
if (prevIndex !== -1) {
lastValidSegmentIndex = prevIndex;
return validatedSegments[prevIndex];
}
return lastValidSegmentIndex !== -1
? validatedSegments[lastValidSegmentIndex]
: null;
}
else if (gapBehavior === "persist-next") {
const nextIndex = findNextSegmentByTime(time);
return nextIndex !== -1 ? validatedSegments[nextIndex] : null;
}
return null;
}
function getSubSegmentWithGapBehavior(time) {
if (currentSegmentIndex !== -1 &&
currentSubSegmentIndex !== -1 &&
Array.isArray(validatedSegments[currentSegmentIndex].subSegments)) {
return (validatedSegments[currentSegmentIndex].subSegments?.[currentSubSegmentIndex] ?? null);
}
if (gapBehavior === "persist-previous" &&
lastValidSegmentIndex !== -1 &&
Array.isArray(validatedSegments[lastValidSegmentIndex].subSegments) &&
(validatedSegments[lastValidSegmentIndex].subSegments?.length ?? 0) > 0) {
const seg = validatedSegments[lastValidSegmentIndex];
return seg.subSegments?.[seg.subSegments.length - 1] ?? null;
}
else if (gapBehavior === "persist-next") {
const nextIndex = findNextSegmentByTime(time);
if (nextIndex !== -1 &&
Array.isArray(validatedSegments[nextIndex].subSegments) &&
(validatedSegments[nextIndex].subSegments?.length ?? 0) > 0) {
const seg = validatedSegments[nextIndex];
return seg.subSegments?.[0] ?? null;
}
}
return null;
}
function handleTimeUpdate() {
const currentTime = tracker.getCurrentTime();
if (currentTime < 0)
return;
const newSegmentIndex = findSegmentIndexByTime(currentTime);
const segmentToReport = getSegmentWithGapBehavior(newSegmentIndex, currentTime);
const segmentChanged = newSegmentIndex !== currentSegmentIndex ||
!isInitialized ||
lastReportedSegment?.id !== segmentToReport?.id;
if (segmentChanged) {
if (lastReportedSegment !== null)
tracker.callbacks.onSegmentExit?.(lastReportedSegment);
currentSegmentIndex = newSegmentIndex;
if (currentSegmentIndex !== -1)
lastValidSegmentIndex = currentSegmentIndex;
if (segmentToReport !== null) {
tracker.callbacks.onSegmentChange?.(segmentToReport);
tracker.callbacks.onSegmentEnter?.(segmentToReport);
lastReportedSegment = segmentToReport;
const newSpeaker = segmentToReport.speaker ?? null;
if (currentSpeaker?.id !== newSpeaker?.id) {
currentSpeaker = newSpeaker;
tracker.callbacks.onSpeakerChange?.(currentSpeaker);
}
}
else {
tracker.callbacks.onSegmentChange?.(null);
lastReportedSegment = null;
if (currentSpeaker !== null) {
currentSpeaker = null;
tracker.callbacks.onSpeakerChange?.(null);
}
}
}
const subSegmentToReport = getSubSegmentWithGapBehavior(currentTime);
if (currentSegmentIndex !== -1) {
const currentSegment = validatedSegments[currentSegmentIndex];
const newSubSegmentIndex = findSubSegmentIndexByTime(currentSegment, currentTime);
if (newSubSegmentIndex !== currentSubSegmentIndex) {
if (lastReportedSubSegment !== null)
tracker.callbacks.onSubSegmentExit?.(lastReportedSubSegment);
currentSubSegmentIndex = newSubSegmentIndex;
if (currentSubSegmentIndex !== -1 &&
Array.isArray(currentSegment.subSegments)) {
const subSeg = currentSegment.subSegments[currentSubSegmentIndex];
tracker.callbacks.onSubSegmentChange?.(subSeg);
tracker.callbacks.onSubSegmentEnter?.(subSeg);
lastReportedSubSegment = subSeg;
}
else {
tracker.callbacks.onSubSegmentChange?.(null);
lastReportedSubSegment = null;
}
}
}
else {
const subSegChanged = lastReportedSubSegment?.id !== subSegmentToReport?.id;
if (subSegChanged) {
if (subSegmentToReport !== null) {
tracker.callbacks.onSubSegmentChange?.(subSegmentToReport);
lastReportedSubSegment = subSegmentToReport;
}
else {
if (currentSubSegmentIndex !== -1) {
currentSubSegmentIndex = -1;
tracker.callbacks.onSubSegmentChange?.(null);
lastReportedSubSegment = null;
}
}
}
}
isInitialized = true;
}
function handleSeeking() {
currentSegmentIndex = -1;
isInitialized = false;
handleTimeUpdate();
}
handleTimeUpdate();
tracker.getAllSegments = () => validatedSegments;
tracker.getSubSegmentsBySegmentId = (id) => {
const segment = validatedSegments.find((seg) => seg.id === id);
return Array.isArray(segment?.subSegments) ? segment.subSegments : [];
};
tracker.getNextSegment = () => {
if (currentSegmentIndex === -1)
return null;
return validatedSegments[currentSegmentIndex + 1] || null;
};
tracker.getPreviousSegment = () => {
if (currentSegmentIndex <= 0)
return null;
return validatedSegments[currentSegmentIndex - 1] || null;
};
tracker.seekToNextSegment = () => {
const nextSegment = tracker.getNextSegment
? tracker.getNextSegment()
: null;
if (nextSegment?.start != null) {
const duration = tracker.getDuration();
const seekTime = Math.min(Math.max(nextSegment.start, 0), duration);
tracker.seekTo(seekTime);
}
};
tracker.seekToPreviousSegment = () => {
const prevSegment = tracker.getPreviousSegment
? tracker.getPreviousSegment()
: null;
if (prevSegment?.start != null) {
const duration = tracker.getDuration();
const seekTime = Math.min(Math.max(prevSegment.start, 0), duration);
tracker.seekTo(seekTime);
}
};
tracker.isInGap = () => {
const currentTime = tracker.getCurrentTime();
return findSegmentIndexByTime(currentTime) === -1;
};
tracker.getGapBehavior = () => gapBehavior;
tracker.getSegmentAtTime = (time) => {
const segIndex = findSegmentIndexByTime(time);
return getSegmentWithGapBehavior(segIndex, time);
};
tracker.getSubSegmentAtTime = (time) => {
const segAtTime = tracker.getSegmentAtTime
? tracker.getSegmentAtTime(time)
: null;
if (!segAtTime)
return null;
const subIndex = findSubSegmentIndexByTime(segAtTime, time);
if (Array.isArray(segAtTime.subSegments) && subIndex !== -1)
return segAtTime.subSegments[subIndex] || null;
if (gapBehavior === "persist-previous" &&
Array.isArray(segAtTime.subSegments) &&
segAtTime.subSegments.length) {
return segAtTime.subSegments[segAtTime.subSegments.length - 1];
}
else if (gapBehavior === "persist-next" &&
Array.isArray(segAtTime.subSegments) &&
segAtTime.subSegments.length) {
return segAtTime.subSegments[0] || null;
}
return null;
};
tracker.getCurrentSegment = () => {
const currentTime = tracker.getCurrentTime();
if (currentSegmentIndex !== -1)
return validatedSegments[currentSegmentIndex];
return getSegmentWithGapBehavior(currentSegmentIndex, currentTime);
};
tracker.getCurrentSubSegment = () => {
const currentTime = tracker.getCurrentTime();
return getSubSegmentWithGapBehavior(currentTime);
};
tracker.getCurrentSpeaker = () => {
const currentSeg = tracker.getCurrentSegment
? tracker.getCurrentSegment()
: null;
return currentSeg?.speaker || null;
};
tracker.seekToSegmentById = (id) => {
if (!id)
return;
const segment = validatedSegments.find((seg) => seg.id === id);
if (segment?.start != null) {
const duration = tracker.getDuration();
const seekTime = Math.min(Math.max(segment.start, 0), duration);
tracker.seekTo(seekTime);
}
};
tracker.seekToSubSegmentById = (id) => {
if (!id)
return;
for (const segment of validatedSegments) {
if (Array.isArray(segment.subSegments) && segment.subSegments.length) {
const sub = segment.subSegments.find((s) => s.id === id);
if (sub?.start != null) {
const duration = tracker.getDuration();
const seekTime = Math.min(Math.max(sub.start, 0), duration);
tracker.seekTo(seekTime);
return;
}
}
}
};
tracker.subscribe("timeupdate", handleTimeUpdate);
tracker.subscribe("seeking", handleSeeking);
return () => {
tracker.unsubscribe("timeupdate", handleTimeUpdate);
tracker.unsubscribe("seeking", handleSeeking);
};
}
//# sourceMappingURL=timestampModule.js.map