diff --git a/README.md b/README.md
index 512233e..7b5775c 100644
--- a/README.md
+++ b/README.md
@@ -400,9 +400,13 @@ cap1.init("hypertranscript", "hyperplayer", '37', '21', undefined, undefined, nu
joinSentences: true, // a short sentence shares the caption before it when the whole sentence fits
maxJoinGap: 1, // ...unless more than this many seconds of silence separate them
paragraphBreaks: true, // a new paragraph always starts a new caption
+ dualSpeakers: true, // two speakers may share a caption: one per line, each line opening with a hyphen
+ leadSentences: true, // a short sentence with nothing to join leads the long sentence after it
});
```
+Each cue in the result's `data` also carries `speakers`: the speaker of each of its lines (`["Ann", "Bob"]` for a dual-speaker caption, `""` where the transcript names none).
+
## :money_with_wings: Web Monetization Support :money_with_wings:
[Web Monetization](https://webmonetization.org/) is a browser API, stewarded by the [Interledger Foundation](https://interledger.org/), that lets visitors stream micropayments to the sites they're reading. There is currently no native browser support — visitors need a [Web Monetization agent](https://webmonetization.org/supporters/get-started/) (browser extension) installed to actually pay.
diff --git a/__TEST__/caption.test.js b/__TEST__/caption.test.js
index e95b6ee..fd1a4fa 100644
--- a/__TEST__/caption.test.js
+++ b/__TEST__/caption.test.js
@@ -389,3 +389,144 @@ test("joined captions never overlap and keep the cue count honest", () => {
expect(cues.map((c) => c.text).join(" "))
.toBe("Yes. No. Maybe. I see. Go on. This one is a good deal longer than a line allows. Fine.");
});
+
+// ---- 2.3.0: the speaker of each line, dual-speaker captions, leading sentences
+
+const lineTexts = (result) => result.data.map((c) => c.text.replace(/\n+$/, "").split("\n").map((l) => l.trim()));
+const INTERVIEW = "@speaker-A to start? | @speaker-B Sure. So to start off, Dr. Ashby, can you just introduce yourself and give us a little insight into your background?";
+
+test("with no new options, captions are exactly what 2.2.0 generated", () => {
+ buildFromText(INTERVIEW);
+ const plain = run({ joinSentences: true, detectAbbreviations: true, abbreviations: ["Dr."] });
+ expect(cueTexts(plain).slice(0, 2)).toEqual(["to start?", "Sure."]);
+ expect(plain.vtt).not.toContain("-to start?");
+ // a label repeating the speaker already talking still starts a new caption
+ buildFromText("@Ann Yes. @Ann No.");
+ expect(cueTexts(run({ joinSentences: true }))).toEqual(["Yes.", "No."]);
+});
+
+test("every cue names the speaker of each of its lines, deep into a turn", () => {
+ buildFromText("@Ann This sentence is far too long to sit on one line of a caption. Yes. | @Bob No. Never. It was not like that at all in those days, I can tell you.");
+ const result = run({ joinSentences: true });
+ result.data.forEach((cue) => {
+ expect(cue.speakers).toHaveLength(cue.text.replace(/\n+$/, "").split("\n").length);
+ });
+ const bySpeaker = (name) => result.data.filter((c) => c.speakers.every((s) => s === name)).map((c) => flat(c.text)).join(" ");
+ expect(bySpeaker("Ann")).toBe("This sentence is far too long to sit on one line of a caption. Yes.");
+ expect(bySpeaker("Bob")).toBe("No. Never. It was not like that at all in those days, I can tell you.");
+ // label styles: "[Ann] " and "Ann: " both read as Ann; no label at all is ''
+ document.body.innerHTML = '
';
+ expect(run().data[0].speakers).toEqual(["Ann"]);
+ buildFromText("Yes.");
+ expect(run().data[0].speakers).toEqual([""]);
+});
+
+test("dualSpeakers: two speakers share a caption, one per line, each line with a hyphen", () => {
+ buildFromText(INTERVIEW);
+ const result = run({ joinSentences: true, dualSpeakers: true, detectAbbreviations: true, abbreviations: ["Dr."] });
+ expect(lineTexts(result)[0]).toEqual(["-to start?", "-Sure."]);
+ expect(result.data[0].speakers).toEqual(["speaker-A", "speaker-B"]);
+ // the caption runs from the first speaker's first word to the second's last
+ const cues = parseVtt(result.vtt);
+ expect(cues[0].start).toBe("00:00:00.000");
+ expect(cues[0].stop).toBe("00:00:01.150"); // "Sure." starts at 0.8s, lasts 350ms
+ // the long sentence after it is one speaker's: no hyphen anywhere else
+ result.data.slice(1).forEach((cue) => {
+ expect(cue.text).not.toMatch(/(^|\n)-/);
+ expect(new Set(cue.speakers)).toEqual(new Set(["speaker-B"]));
+ });
+ expect(result.srt).toMatch(/-to start\? ?\n-Sure\./);
+});
+
+test("dualSpeakers: a hyphen marks a shared caption, never just a change of speaker", () => {
+ // the pause is too long to share: two captions, neither with a hyphen
+ buildTranscript([
+ ["speaker", "[Ann] "], [0, 300, "Ready?"],
+ ["speaker", "[Bob] "], [3000, 300, "Sure."],
+ ]);
+ const apart = run({ dualSpeakers: true });
+ expect(cueTexts(apart)).toEqual(["Ready?", "Sure."]);
+ expect(apart.data.map((c) => c.speakers)).toEqual([["Ann"], ["Bob"]]);
+});
+
+test("dualSpeakers: two speakers at most, whole sentences only, and the hyphen counts", () => {
+ // a third turn starts a new caption
+ buildFromText("@Ann Ready? @Bob Sure. @Ann Good.");
+ expect(lineTexts(run({ dualSpeakers: true }))).toEqual([["-Ready?", "-Sure."], ["Good."]]);
+
+ // several short sentences from one speaker can make up a side
+ buildFromText("@Ann Yes. No. @Bob Fine. Go on.");
+ const sides = run({ joinSentences: true, dualSpeakers: true });
+ expect(lineTexts(sides)).toEqual([["-Yes. No.", "-Fine. Go on."]]);
+ expect(sides.data[0].speakers).toEqual(["Ann", "Bob"]);
+
+ // the tail of a long sentence is not a whole sentence: nothing shares with it
+ buildFromText("@Ann This sentence is far too long to sit on one line. @Bob Sure.");
+ const tail = lineTexts(run({ dualSpeakers: true }));
+ expect(tail[tail.length - 1]).toEqual(["Sure."]);
+
+ // a line filled to the limit by joining has no room left for its hyphen
+ buildFromText("@Ann Yes. No. @Bob Fine.");
+ expect(lineTexts(run({ joinSentences: true, dualSpeakers: true }, 9, 5))).toEqual([["Yes. No."], ["Fine."]]);
+ buildFromText("@Ann Yes. No. @Bob Fine.");
+ expect(lineTexts(run({ joinSentences: true, dualSpeakers: true }, 10, 5))).toEqual([["-Yes. No.", "-Fine."]]);
+});
+
+test("dualSpeakers: paragraphBreaks wins, and a transcript with no labels is untouched", () => {
+ buildFromText("@Ann Ready? | @Bob Sure.");
+ expect(lineTexts(run({ dualSpeakers: true, paragraphBreaks: true }))).toEqual([["Ready?"], ["Sure."]]);
+ buildFromText("Yes. No. Maybe. I see.");
+ const withOption = run({ joinSentences: true, dualSpeakers: true, leadSentences: true });
+ buildFromText("Yes. No. Maybe. I see.");
+ expect(withOption.vtt).toBe(run({ joinSentences: true }).vtt);
+});
+
+test("leadSentences: a short sentence leads the long sentence after it", () => {
+ buildFromText("@speaker-B Sure. So to start off, Dr. Ashby, can you just introduce yourself and give us a little insight into your background?");
+ const options = { joinSentences: true, detectAbbreviations: true, abbreviations: ["Dr."] };
+ expect(cueTexts(run(options))[0]).toBe("Sure.");
+ const led = run({ ...options, leadSentences: true });
+ expect(cueTexts(led)[0].startsWith("Sure. So to start off,")).toBe(true);
+ // nothing lost, nothing over the line length, one speaker throughout
+ expect(cueTexts(led).join(" ")).toBe("Sure. So to start off, Dr. Ashby, can you just introduce yourself and give us a little insight into your background?");
+ lineTexts(led)[0].forEach((line) => expect(line.length).toBeLessThanOrEqual(32));
+ led.data.forEach((cue) => expect(new Set(cue.speakers)).toEqual(new Set(["speaker-B"])));
+ expect(parseVtt(led.vtt)[0].start).toBe("00:00:00.000");
+});
+
+test("leadSentences: not across a speaker, a long pause, or a paragraph that must break; joining back comes first", () => {
+ const LONG = "This sentence is far too long to sit on one line of a caption.";
+ buildFromText(`Sure. @Bob ${LONG}`);
+ expect(cueTexts(run({ leadSentences: true }))[0]).toBe("Sure.");
+
+ buildFromText(`Sure. | ${LONG}`);
+ expect(cueTexts(run({ leadSentences: true, paragraphBreaks: true }))[0]).toBe("Sure.");
+ expect(cueTexts(run({ leadSentences: true }))[0]).not.toBe("Sure.");
+
+ buildTranscript([[0, 300, "Sure."], ...LONG.split(" ").map((w, i) => [5000 + i * 400, 350, w])]);
+ expect(cueTexts(run({ leadSentences: true }))[0]).toBe("Sure.");
+
+ // "Yes." has a caption behind it to join: it goes back, not forward
+ buildFromText(`No. Yes. ${LONG}`);
+ expect(cueTexts(run({ joinSentences: true, leadSentences: true }))[0]).toBe("No. Yes.");
+});
+
+test("together: the interview opening, as the pause allows", () => {
+ const options = { joinSentences: true, dualSpeakers: true, leadSentences: true, detectAbbreviations: true, abbreviations: ["Dr."] };
+ buildFromText(INTERVIEW);
+ expect(lineTexts(run(options))[0]).toEqual(["-to start?", "-Sure."]);
+
+ // a long pause before the answer: no shared caption, and "Sure." leads instead
+ const answer = "Sure. So to start off, Dr. Ashby, can you just introduce yourself?".split(" ");
+ buildTranscript([
+ ["speaker", "[speaker-A] "], [0, 300, "to"], [350, 300, "start?"],
+ ["speaker", "[speaker-B] "], ...answer.map((w, i) => [4000 + i * 400, 350, w]),
+ ]);
+ const apart = run(options);
+ expect(cueTexts(apart)[0]).toBe("to start?");
+ expect(cueTexts(apart)[1].startsWith("Sure. So to start off,")).toBe(true);
+ expect(apart.vtt).not.toMatch(/\n-/);
+ // and no cue overlaps the next
+ const cues = parseVtt(apart.vtt);
+ for (let i = 1; i < cues.length; i += 1) expect(cues[i].start >= cues[i - 1].stop).toBe(true);
+});
diff --git a/js/caption.d.ts b/js/caption.d.ts
index 99cfc10..119b162 100644
--- a/js/caption.d.ts
+++ b/js/caption.d.ts
@@ -7,6 +7,8 @@ export interface CaptionCue {
/** "HH:MM:SS.mmm" */
stop: string;
text: string;
+ /** The speaker of each line of `text`, '' where the transcript names none. */
+ speakers: string[];
}
export interface CaptionsResult {
@@ -38,6 +40,17 @@ export interface CaptionOptions {
maxJoinGap?: number;
/** A new paragraph always starts a new caption. */
paragraphBreaks?: boolean;
+ /**
+ * Two short sentences from different speakers may share a caption: two
+ * speakers at most, one per line, each line opening with a hyphen. A
+ * caption with one speaker never has a hyphen. `paragraphBreaks` wins.
+ */
+ dualSpeakers?: boolean;
+ /**
+ * A short sentence that could not join the caption before it leads the long
+ * sentence after it, when both are one speaker's and within maxJoinGap.
+ */
+ leadSentences?: boolean;
}
export interface CaptionInstance {
diff --git a/js/caption.js b/js/caption.js
index e884281..0e74c92 100644
--- a/js/caption.js
+++ b/js/caption.js
@@ -1,5 +1,5 @@
/*! (C) The Hyperaudio Project. MIT @license: en.wikipedia.org/wiki/MIT_License. */
-/*! Version 2.2.0 */
+/*! Version 2.3.0 */
'use strict';
const caption = function () {
@@ -47,6 +47,18 @@ const caption = function () {
// joined across a speaker label or a long pause.
// maxJoinGap seconds of silence that still allow a join (default 1)
// paragraphBreaks true: a new paragraph always starts a new caption
+ // dualSpeakers true: two short sentences from different speakers may
+ // share a caption, in the Netflix form - two speakers
+ // at most, one per line, each line opening with a
+ // hyphen. The hyphen marks two speakers SHARING a
+ // caption, not a change of speaker: a caption with
+ // one speaker never has one. paragraphBreaks wins.
+ // leadSentences true: a short sentence that could not join the
+ // caption before it leads the long sentence after
+ // it, when both are one speaker's and close in time.
+ //
+ // Every cue in the result's data carries `speakers`: the speaker of each of
+ // its lines, '' where the transcript names none.
cap.init = function (transcriptId, playerId, maxLength, minLength, label, srclang, parent, options) {
const opts = options || {};
@@ -58,6 +70,8 @@ const caption = function () {
const joinSentences = opts.joinSentences === true;
const paragraphBreaks = opts.paragraphBreaks === true;
const maxJoinGap = typeof opts.maxJoinGap === 'number' && opts.maxJoinGap >= 0 ? opts.maxJoinGap : 1;
+ const dualSpeakers = opts.dualSpeakers === true;
+ const leadSentences = opts.leadSentences === true;
let transcript = document.getElementById(transcriptId);
@@ -159,11 +173,18 @@ const caption = function () {
minLineLength = minLength;
}
+ // A label as the transcript writes it ("[Ann] ", "Ann: ") to a name.
+ const speakerName = (label) => String(label).trim().replace(/^\[(.*)\]$/, '$1').replace(/:$/, '').trim();
+ // Only the sentence a label opens has `speaker` set; the name is carried
+ // through the rest of the turn here, so every caption knows whose it is.
+ let currentSpeaker = '';
+
words.forEach((word, i) => {
if (thisSegmentMeta === null) {
// create segment meta object
thisSegmentMeta = new segmentMeta('', null, 0, 0, 0);
}
+ thisSegmentMeta.speakerName = currentSpeaker;
if (word.classList.contains('speaker')) {
// checking that this is not a new segment AND a new empty segment wasn't already created
@@ -175,6 +196,8 @@ const caption = function () {
// textContent, not innerText: identical for plain transcript spans,
// doesn't force a layout pass, and works in jsdom for tests.
thisSegmentMeta.speaker = word.textContent;
+ currentSpeaker = speakerName(word.textContent);
+ thisSegmentMeta.speakerName = currentSpeaker;
} else {
// A new paragraph (paragraphBreaks): close the sentence in hand, so no
// caption runs across the break even where the paragraph ended without
@@ -186,6 +209,7 @@ const caption = function () {
if (thisSegmentMeta.start !== null) {
data.segments.push(thisSegmentMeta);
thisSegmentMeta = new segmentMeta('', null, 0, 0, 0);
+ thisSegmentMeta.speakerName = currentSpeaker;
}
thisSegmentMeta.paragraphStart = true;
}
@@ -252,6 +276,7 @@ const caption = function () {
this.start = start;
this.stop = stop;
this.text = text;
+ this.speakers = []; // the speaker of each line; filled in once the lines are final
}
const captions = [];
@@ -262,16 +287,52 @@ const caption = function () {
// timing safeguards may later extend.
const speechEnd = new Map();
+ // Whose each caption is, and which captions hold nothing but whole
+ // sentences - only those may become one side of a dual-speaker caption.
+ const owner = new Map();
+ const lineSpeakers = new Map(); // set only where the lines differ
+ const wholeSentences = new Set();
+ const claim = (segment, from) => {
+ for (let c = from; c < captions.length; c++) owner.set(captions[c], segment.speakerName);
+ };
+
+ // dualSpeakers: a short sentence that opens another speaker's turn shares
+ // the one-line caption before it - "-to start?" / "-Sure." Each line is
+ // one speaker's whole sentences, and the hyphens count towards its length.
+ function joinAsSecondSpeaker(prev, segment, text, segmentStop) {
+ if (!dualSpeakers || paragraphBreaks) return false;
+ if (!wholeSentences.has(prev) || lineSpeakers.has(prev)) return false;
+ const first = prev.text.replace(/\n+$/, '');
+ if (first.includes('\n')) return false;
+ if (('-' + first).length > maxLineLength || ('-' + text).length > maxLineLength) return false;
+ lineSpeakers.set(prev, [owner.get(prev) || '', segment.speakerName]);
+ prev.text = '-' + first + '\n-' + text;
+ prev.stop = formatSeconds(segmentStop);
+ speechEnd.set(prev, segmentStop);
+ return true;
+ }
+
// joinSentences: put a whole short sentence into the caption before it.
// On the same line when it fits there, else as the second line of a
- // one-line caption. Never across a speaker label, a paragraph that must
- // break, or a pause longer than maxJoinGap.
+ // one-line caption. Never across a paragraph that must break or a pause
+ // longer than maxJoinGap, and across a speaker label only as the second
+ // speaker of a dual-speaker caption.
function joinToPrevious(segment, text, segmentStop) {
const prev = captions[captions.length - 1];
- if (!joinSentences || prev === undefined) return false;
- if (segment.speaker !== '' || segment.paragraphStart === true) return false;
+ if (prev === undefined || segment.paragraphStart === true) return false;
const prevEnd = speechEnd.get(prev);
if (prevEnd === undefined || segment.start - prevEnd > maxJoinGap) return false;
+ // A label always ends ordinary joining, as it did in 2.2.0. What it may
+ // do now is open the second line of a dual-speaker caption - when it
+ // really is another speaker, not the same name written again.
+ if (segment.speaker !== '') {
+ return segment.speakerName !== (owner.get(prev) || '')
+ && joinAsSecondSpeaker(prev, segment, text, segmentStop);
+ }
+ if (!joinSentences) return false;
+ // further sentences of a dual-speaker caption's second speaker go on
+ // their line; the first speaker's line is closed
+ if (lineSpeakers.has(prev) && lineSpeakers.get(prev)[1] !== segment.speakerName) return false;
const lines = prev.text.replace(/\n+$/, '').split('\n');
const last = lines[lines.length - 1];
@@ -317,6 +378,23 @@ const caption = function () {
return; // the sentence went into the caption before it
}
+ // leadSentences: nothing behind to join, and the same speaker's next
+ // sentence is too long for one line - lay the two out as one run, so
+ // "Sure." opens that sentence's first caption instead of standing
+ // alone. The long sentence is divided across captions either way.
+ const next = arr[i + 1];
+ if (leadSentences && next !== undefined && segment.chars < minLineLength
+ && next.speaker === '' && next.paragraphStart !== true
+ && next.chars >= maxLineLength && next.start - segmentStop <= maxJoinGap) {
+ next.words = segment.words.concat(next.words);
+ next.chars += segment.chars;
+ next.duration += segment.duration;
+ next.start = segment.start;
+ next.speaker = segment.speaker; // it opens the turn now, if this did
+ next.paragraphStart = segment.paragraphStart;
+ return;
+ }
+
thisCaption = new captionMeta(
formatSeconds(segment.start),
formatSeconds(segmentStop),
@@ -328,6 +406,8 @@ const caption = function () {
//console.log(thisCaption);
captions.push(thisCaption);
speechEnd.set(thisCaption, segmentStop);
+ wholeSentences.add(thisCaption);
+ claim(segment, segmentStartCount);
thisCaption = null;
} else {
// The number of chars in this segment is longer than our single line maximum
@@ -480,9 +560,16 @@ const caption = function () {
if (captions.length > segmentStartCount && lastOutTime !== undefined) {
speechEnd.set(captions[captions.length - 1], lastOutTime);
}
+ claim(segment, segmentStartCount);
}
});
+ // the speaker of each line, now that the lines are final
+ captions.forEach((caption) => {
+ const lineCount = caption.text.replace(/\n+$/, '').split('\n').length;
+ caption.speakers = lineSpeakers.get(caption) || new Array(lineCount).fill(owner.get(caption) || '');
+ });
+
// Enforce a comfortable minimum on-screen time for each cue. A cue is
// extended (never shortened) so it lasts at least minCaptionDuration and
// long enough to read its text at readingSpeedCps, but only as far as the