@makefully/dutifully 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ const
2
+ fs = require('fs').promises,
3
+ os = require('os'),
4
+ File = require("./File"),
5
+ getSeparater = (str) => str.indexOf(os.EOL) >= 0 ? os.EOL : '\n';
6
+
7
+ module.exports = class LRC extends File {
8
+ async load (path) {
9
+ await super.load(path);
10
+ this.data = await fs.readFile(path, 'utf8');
11
+ return this.data;
12
+ }
13
+
14
+ addHash (hash) {
15
+ this.data = this.data.replace(/^\[hash:[a-f0-9]+\]\n?/im, '');
16
+
17
+ const
18
+ sep = getSeparater(this.data),
19
+ insertAfter = this.data.match(/^\[generator:[^\]]*\]/im);
20
+
21
+ if (insertAfter) {
22
+ this.data = this.data.replace(insertAfter[0], `${insertAfter[0]}${sep}[hash:${hash}]`);
23
+ } else {
24
+ this.data = `[hash:${hash}]${sep}${this.data}`;
25
+ }
26
+ }
27
+
28
+ getHash () {
29
+ const
30
+ match = this.data.match(/^\[hash:([a-f0-9]+)\]/im);
31
+
32
+ return match ? match[1] : null;
33
+ }
34
+
35
+ toString () {
36
+ return this.data.split(getSeparater(this.data)).filter((line) => line.length > 0 && line.indexOf('[') === 0 && line.indexOf(']') === 9).map((text) => text.substring(10)).join(' ');
37
+ }
38
+ }
@@ -0,0 +1,60 @@
1
+ const
2
+ id3 = require('node-id3').Promise,
3
+ File = require("./File");
4
+
5
+ module.exports = class MP3 extends File {
6
+ async load (path) {
7
+ await super.load(path);
8
+ this.data = await id3.read(path);
9
+ return this.data;
10
+ }
11
+
12
+ addHash (hash, {hashKey = 'CaptionHash'} = {}) {
13
+ const
14
+ existing = (this.data.userDefinedText ?? []).filter(({description}) => description !== hashKey);
15
+
16
+ this.data.userDefinedText = [...existing, {description: hashKey, value: hash}];
17
+ }
18
+
19
+ getHash ({hashKey = 'CaptionHash'} = {}) {
20
+ const
21
+ {userDefinedText} = this.data;
22
+
23
+ if (userDefinedText) {
24
+ for (let i = 0; i < userDefinedText.length; i++) {
25
+ if (userDefinedText[i].description === hashKey) {
26
+ return userDefinedText[i].value;
27
+ }
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ save () { // return a promise
35
+ return id3.write(this.data, this.path);
36
+ }
37
+
38
+ toString () {
39
+ const
40
+ {synchronisedLyrics, unsynchronisedLyrics} = this.data;
41
+
42
+ if (unsynchronisedLyrics?.text) {
43
+ return unsynchronisedLyrics.text;
44
+ }
45
+
46
+ if (synchronisedLyrics) {
47
+ const
48
+ index = synchronisedLyrics.map(({shortText}) => shortText).indexOf('captions');
49
+
50
+ if (index >= 0) {
51
+ const
52
+ {synchronisedText} = synchronisedLyrics[index];
53
+
54
+ return synchronisedText.map(({text}) => text).join(' ');
55
+ }
56
+ }
57
+
58
+ return '';
59
+ }
60
+ }
@@ -0,0 +1,59 @@
1
+ const
2
+ fs = require('fs').promises,
3
+ File = require("./File"),
4
+ removeComments = (html) => html.replace(/<!--[\s\S]*?-->/g, ''),
5
+ cleanHTML = (html) => html
6
+ .replace(/<[^>]+>/g, '') // remove HTML tags
7
+ .replace(/&nbsp;/gi, ' ') // decode common HTML entities
8
+ .replace(/&amp;/gi, '&')
9
+ .replace(/&lt;/gi, '<')
10
+ .replace(/&gt;/gi, '>')
11
+ .replace(/&quot;/gi, '"')
12
+ .trim();
13
+
14
+ module.exports = class SAMI extends File {
15
+ async load (path) {
16
+ await super.load(path);
17
+ this.data = await fs.readFile(path, 'utf8');
18
+ return this.data;
19
+ }
20
+
21
+ addHash (hash) {
22
+ this.data = this.data.replace(/<META[\s]+Hash="[a-f0-9]+"[\s]*\/>\n?/i, '');
23
+
24
+ const
25
+ insertAfter = this.data.match(/<META[\s]+Generator="[^"]*"[\s]*\/>/i);
26
+
27
+ if (insertAfter) {
28
+ this.data = this.data.replace(insertAfter[0], `${insertAfter[0]}\n <META Hash="${hash}"/>`);
29
+ } else {
30
+ this.data = this.data.replace(/<HEAD>/i, `<HEAD>\n <META Hash="${hash}"/>`);
31
+ }
32
+ }
33
+
34
+ getHash () {
35
+ const
36
+ match = this.data.match(/<META[\s]+Hash="([a-f0-9]+)"/i);
37
+
38
+ return match ? match[1] : null;
39
+ }
40
+
41
+ toString () {
42
+ const
43
+ sami = removeComments(this.data),
44
+ regex = /<P[^>]*>([\s\S]*?)<\/P>/gi,
45
+ matches = [];
46
+ let match;
47
+
48
+ while ((match = regex.exec(sami)) !== null) {
49
+ const
50
+ innerText = cleanHTML(match[1]);
51
+
52
+ if (innerText) {
53
+ matches.push(innerText);
54
+ }
55
+ }
56
+
57
+ return matches.join(' ');
58
+ }
59
+ }
@@ -0,0 +1,40 @@
1
+ const
2
+ fs = require('fs').promises,
3
+ os = require('os'),
4
+ File = require("./File"),
5
+ getSeparater = (str) => str.indexOf(os.EOL) >= 0 ? os.EOL : '\n';
6
+
7
+ module.exports = class SRT extends File {
8
+ async load (path) {
9
+ await super.load(path);
10
+ this.data = await fs.readFile(path, 'utf8');
11
+ return this.data;
12
+ }
13
+
14
+ addHash (hash) {
15
+ this.data = this.data.replace(/<u[\s]+hash="[a-f0-9]+"[\s]*(?:\/>|><\/u>)\n?/im, '');
16
+
17
+ const
18
+ insertAfter = this.data.match(/<u[\s]+generator="[^"]*"[\s]*(?:\/>|><\/u>)/im);
19
+
20
+ if (insertAfter) {
21
+ this.data = this.data.replace(insertAfter[0], `${insertAfter[0]}${getSeparater(this.data)}<u hash="${hash}"></u>`);
22
+ } else {
23
+ const
24
+ sep = getSeparater(this.data);
25
+
26
+ this.data = `1${sep}00:00:00,000 --> 00:00:00,001${sep}<u hash="${hash}"></u>${sep}${sep}${this.data}`;
27
+ }
28
+ }
29
+
30
+ getHash () {
31
+ const
32
+ match = this.data.match(/<u[\s]+hash="([a-f0-9]+)"[\s]*(?:\/>|><\/u>)/im);
33
+
34
+ return match ? match[1] : null;
35
+ }
36
+
37
+ toString () {
38
+ return this.data.split(getSeparater(this.data)).filter((line) => line.length > 0 && line.indexOf(' --> ') === -1 && parseInt(line) != line).join(' ');
39
+ }
40
+ }
@@ -0,0 +1,38 @@
1
+ const
2
+ fs = require('fs').promises,
3
+ os = require('os'),
4
+ File = require("./File"),
5
+ getSeparater = (str) => str.indexOf(os.EOL) >= 0 ? os.EOL : '\n';
6
+
7
+ module.exports = class VTT extends File {
8
+ async load (path) {
9
+ await super.load(path);
10
+ this.data = await fs.readFile(path, 'utf8');
11
+ return this.data;
12
+ }
13
+
14
+ addHash (hash) {
15
+ this.data = this.data.replace(/^NOTE[\s]+Hash:[\s]+[a-f0-9]+\n?/im, '');
16
+
17
+ const
18
+ sep = getSeparater(this.data),
19
+ insertAfter = this.data.match(/^NOTE[\s]+Generator:[^\n]*/im);
20
+
21
+ if (insertAfter) {
22
+ this.data = this.data.replace(insertAfter[0], `${insertAfter[0]}${sep}NOTE Hash: ${hash}`);
23
+ } else {
24
+ this.data = this.data.replace('WEBVTT', `WEBVTT${sep}${sep}NOTE Hash: ${hash}`);
25
+ }
26
+ }
27
+
28
+ getHash () {
29
+ const
30
+ match = this.data.match(/^NOTE[\s]+Hash:[\s]+([a-f0-9]+)/im);
31
+
32
+ return match ? match[1] : null;
33
+ }
34
+
35
+ toString () {
36
+ return this.data.split(getSeparater(this.data)).filter((line) => line.length > 0 && line.indexOf(' --> ') === -1 && parseInt(line) != line).join(' ');
37
+ }
38
+ }
@@ -0,0 +1,44 @@
1
+ const
2
+ fs = require('fs').promises,
3
+ getFileData = require("./getFileData"),
4
+ filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`);
5
+
6
+ module.exports = async (path, fileType) => {
7
+
8
+ // "captions.json" is a proprietary format that includes all captions.
9
+ if (fileType.toLowerCase() === 'json') {
10
+ const
11
+ file = await getFileData(`${path}captions.json`, 'json'),
12
+ {data: json} = file;
13
+
14
+ return {
15
+ captions: Object.keys(json).reduce((obj, key) => {
16
+ obj[key] = json[key].map(({content}) => content).join(' ');
17
+ return obj;
18
+ }, {}),
19
+ file,
20
+ files: null
21
+ };
22
+ } else { // Otherwise we pull all the files and construct a key/value object.
23
+ const
24
+ files = (await fs.readdir(path)).filter(filter.bind(null, fileType)),
25
+ captions = {},
26
+ fileMap = {};
27
+
28
+ for (let i = 0; i < files.length; i++) {
29
+ const
30
+ filename = files[i],
31
+ file = await getFileData(`${path}${filename}`, fileType),
32
+ key = filename.substring(0, filename.length - fileType.length - 1);
33
+
34
+ captions[key] = file.toString();
35
+ fileMap[key] = file;
36
+ }
37
+
38
+ return {
39
+ captions,
40
+ file: null,
41
+ files: fileMap
42
+ };
43
+ }
44
+ };
@@ -0,0 +1,33 @@
1
+ const
2
+ JSONFile = require("../formats/JSONFile"),
3
+ LRC = require("../formats/LRC"),
4
+ MP3 = require("../formats/MP3"),
5
+ SAMI = require("../formats/SAMI"),
6
+ SRT = require("../formats/SRT"),
7
+ VTT = require("../formats/VTT"),
8
+ formats = {
9
+ json: JSONFile,
10
+ lrc: LRC,
11
+ mp3: MP3,
12
+ sami: SAMI,
13
+ smi: SAMI,
14
+ srt: SRT,
15
+ vtt: VTT
16
+ };
17
+
18
+ module.exports = async (path, fileType) => {
19
+ const
20
+ lcFormat = fileType.toLowerCase(),
21
+ File = formats[lcFormat] ?? formats.JSONFile;
22
+
23
+ // Find file type and throw if that doesn't work.
24
+ if (File) {
25
+ const
26
+ file = new File({fileType: lcFormat});
27
+
28
+ await file.load(path);
29
+ return file;
30
+ } else {
31
+ throw `"${fileType}" not supported.`;
32
+ }
33
+ };
@@ -0,0 +1,33 @@
1
+ const
2
+ fs = require('fs'),
3
+ getJSON = (path) => new Promise ((resolve, reject) => {
4
+ fs.readFile(path, (err, data) => {
5
+ if (err) {
6
+ reject(err);
7
+ } else {
8
+ let json = null;
9
+
10
+ try {
11
+ json = JSON.parse(data);
12
+ } catch (e) {
13
+ reject(e);
14
+ }
15
+
16
+ if (json) {
17
+ resolve(json);
18
+ }
19
+ }
20
+ });
21
+ });
22
+
23
+ module.exports = async (path) => {
24
+ let json = null;
25
+
26
+ try {
27
+ json = await getJSON(path);
28
+ } catch (e) {
29
+ console.warn(e);
30
+ }
31
+
32
+ return json;
33
+ };
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ /* eslint-disable no-sync */
2
+ /* global console, process, require */
3
+ const
4
+ LipSync = require('./classes/LipSync'),
5
+ parsers = {
6
+ allosaurus: LipSync,
7
+ elevenlabs: require('./classes/ElevenLabs'),
8
+ ffmpeg: require('./classes/FFMPEG'),
9
+ polly: require('./classes/Polly'),
10
+ rasterize: require('./classes/Rasterize'),
11
+ rhubarb: LipSync,
12
+ transcription: require('./classes/Transcription')
13
+ },
14
+ getJSON = require('./helpers/getJSON'),
15
+ send = function (contents) {
16
+ const
17
+ {id, service} = contents,
18
+ configs = Array.isArray(contents[service]) ? contents[service] : [contents[service]];
19
+
20
+ configs.forEach(async (config, index, {length}) => {
21
+ if (!config) {
22
+ console.warn(`Empty configuration for "${service}" service.`);
23
+ } else {
24
+ const
25
+ serviceHandler = new parsers[service]({config, contents});
26
+
27
+ try {
28
+ await serviceHandler.prepare({...contents, ...config});
29
+ } catch (e) {
30
+ console.warn(e.message);
31
+ return;
32
+ }
33
+
34
+ await serviceHandler.send({
35
+ instanceId: `${id}-${service}${length > 1 ? `-${index}` : ''}`
36
+ });
37
+ }
38
+ });
39
+ };
40
+
41
+ const cheer = async (cmdArgs) => {
42
+ const
43
+ package = await getJSON('./package.json') ?? {},
44
+ config = await getJSON('./cheerfully.json') ?? {},
45
+ env = await getJSON('./env-cheerfully.json') ?? {};
46
+
47
+ send({
48
+ id: `${package.name}-${package.version}`,
49
+ package,
50
+ ...config,
51
+ ...env,
52
+ ...cmdArgs
53
+ });
54
+ };
55
+
56
+ cheer.say = require('./say');
57
+
58
+ module.exports = cheer;
package/src/say.js ADDED
@@ -0,0 +1,141 @@
1
+ /* eslint-disable no-sync */
2
+ /* global console, process, require */
3
+ const
4
+ fs = require('fs'),
5
+ fsp = require('fs').promises,
6
+ path = require('path'),
7
+ {createHash} = require('crypto'),
8
+ {AudioContext} = require('node-web-audio-api'),
9
+ ElevenLabs = require('./classes/ElevenLabs'),
10
+ getJSON = require('./helpers/getJSON'),
11
+ CACHE_DIR = '.cache/tts',
12
+ resolveVoiceId = (voice) => {
13
+ if (!voice) {
14
+ return null;
15
+ }
16
+
17
+ if (typeof voice === 'string') {
18
+ return voice;
19
+ }
20
+
21
+ return voice.default ?? Object.values(voice)[0] ?? null;
22
+ },
23
+ cacheKey = ({voice, text}) => createHash('sha256')
24
+ .update(JSON.stringify({
25
+ version: 1,
26
+ voice,
27
+ text
28
+ }))
29
+ .digest('hex'),
30
+ playMp3 = async (file) => {
31
+ const
32
+ audioContext = new AudioContext(),
33
+ data = await fsp.readFile(file),
34
+ arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
35
+ buffer = await audioContext.decodeAudioData(arrayBuffer),
36
+ source = audioContext.createBufferSource();
37
+
38
+ source.buffer = buffer;
39
+ source.connect(audioContext.destination);
40
+
41
+ await new Promise((resolve, reject) => {
42
+ source.onended = resolve;
43
+ try {
44
+ source.start();
45
+ } catch (e) {
46
+ reject(e);
47
+ }
48
+ });
49
+
50
+ await audioContext.close();
51
+ },
52
+ generate = async ({text, voice, key, contents, elevenlabsConfig}) => {
53
+ const
54
+ output = `${CACHE_DIR}/`,
55
+ config = {
56
+ ...elevenlabsConfig,
57
+ files: {
58
+ [key]: text
59
+ },
60
+ path: '',
61
+ voice,
62
+ output
63
+ },
64
+ handler = new ElevenLabs({
65
+ config,
66
+ contents: {
67
+ ...contents,
68
+ service: 'elevenlabs'
69
+ }
70
+ });
71
+
72
+ delete config.script;
73
+
74
+ await handler.prepare({
75
+ difference: false,
76
+ extract: true
77
+ });
78
+ await handler.send({
79
+ instanceId: `${contents.id}-say`
80
+ });
81
+
82
+ return path.join(CACHE_DIR, `${key}.mp3`);
83
+ };
84
+
85
+ module.exports = async (textOrArgs = {}) => {
86
+ const
87
+ isString = typeof textOrArgs === 'string',
88
+ cmdArgs = isString ? {} : (textOrArgs ?? {}),
89
+ text = (isString ? textOrArgs : (cmdArgs.text ?? '')).trim();
90
+
91
+ if (!text) {
92
+ throw new Error('Usage: say "Hello world"');
93
+ }
94
+
95
+ const
96
+ packageJson = await getJSON('./package.json') ?? {},
97
+ cheerfully = await getJSON('./cheerfully.json') ?? {},
98
+ env = await getJSON('./env-cheerfully.json') ?? {},
99
+ contents = {
100
+ id: `${packageJson.name ?? 'cheerfully'}-${packageJson.version ?? '0'}`,
101
+ package: packageJson,
102
+ ...cheerfully,
103
+ ...env,
104
+ ...cmdArgs
105
+ },
106
+ elevenlabsConfig = Array.isArray(contents.elevenlabs)
107
+ ? (contents.elevenlabs[0] ?? {})
108
+ : (contents.elevenlabs ?? {}),
109
+ voice = elevenlabsConfig.voice,
110
+ voiceId = resolveVoiceId(voice);
111
+
112
+ if (!voiceId) {
113
+ throw new Error('No ElevenLabs voice configured. Set elevenlabs[0].voice in cheerfully.json.');
114
+ }
115
+
116
+ if (!contents.server || !contents.accessToken) {
117
+ throw new Error('Missing server/accessToken. Set them in env-cheerfully.json.');
118
+ }
119
+
120
+ await fsp.mkdir(CACHE_DIR, {recursive: true});
121
+
122
+ const
123
+ key = cacheKey({
124
+ voice: voiceId,
125
+ text
126
+ }),
127
+ mp3File = path.join(CACHE_DIR, `${key}.mp3`);
128
+
129
+ if (!fs.existsSync(mp3File)) {
130
+ console.log('Generating speech...');
131
+ await generate({
132
+ text,
133
+ voice,
134
+ key,
135
+ contents,
136
+ elevenlabsConfig
137
+ });
138
+ }
139
+
140
+ await playMp3(mp3File);
141
+ };