@makefully/dutifully 1.10.1 → 1.12.0

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.
@@ -1,205 +1,216 @@
1
- const
2
- Cheer = require('./Cheer'),
3
- Transcript = require('./Transcript'),
4
- fs = require('fs').promises,
5
- getFileData = require('../helpers/getFileData'),
6
- id3 = require('node-id3').Promise,
7
- isoLanguageMap = {
8
- 'en-US': 'eng',
9
- 'es-ES': 'spa'
10
- },
11
- matches = (file, types) => types.reduce((prev, current) => prev || (file.slice(-(current.length + 1)) === `.${current}`), false),
12
- getMP3s = async ({encodedBy = '', output = './', fileTypes = [], transcripts}) => {
13
- const
14
- all = (await fs.readdir(output))
15
- .filter((file) => (file.indexOf('.') !== 0) && (fileTypes.length === 0 || matches(file, fileTypes)));
16
-
17
- if (transcripts) {
18
- const
19
- keys = Object.keys(transcripts),
20
- list = keys.map((id) => `${id}.mp3`),
21
- missing = [],
22
- present = [];
23
-
24
- // need to check for presence _and_ whether lyrics match.
25
- for (let i = 0; i < list.length; i++) {
26
- const
27
- file = list[i];
28
-
29
- if (all.indexOf(file) === -1) {
30
- missing.push(file)
31
- } else { // not actually missing, so let's check the artist and lyrics.
32
- const
33
- fileHash = (await getFileData(`${output}${file}`, 'mp3')).getHash({
34
- hashKey: 'GenerationHash'
35
- });
36
-
37
- // We'll assume the original file should be kept if it doesn't include a generation hash.
38
- if (fileHash && fileHash !== transcripts[keys[i]].getHash({
39
- seed: encodedBy
40
- })) {
41
- missing.push(file);
42
- } else {
43
- present.push(file);
44
- }
45
- }
46
- }
47
-
48
- return {
49
- missing,
50
- present,
51
- unlisted: all.filter((id) => list.indexOf(id) === -1),
52
- all
53
- };
54
- }
55
-
56
- return {
57
- all
58
- };
59
- },
60
- captionText = (caption) => (Array.isArray(caption) ? caption : [caption])
61
- .map((cap) => cap?.caption ?? cap ?? null)
62
- .filter((cap) => cap !== null)
63
- .join(' '),
64
- clamp = (text, max) => {
65
- if (text.length <= max) {
66
- return text;
67
- } else {
68
- const
69
- arr = text.substring(0, max).split(' ');
70
-
71
- if (arr.length > 1) {
72
- arr.length -= 1; // remove last word which may be partial.
73
- return `${arr.join(' ')}...`;
74
- } else {
75
- return arr[0];
76
- }
77
- }
78
- },
79
- appendMP3Meta = ({album, captions, generated = false, language, output, voice}) => {
80
- Object.keys(captions).forEach(async (id) => {
81
- const
82
- text = captionText(captions[id]),
83
- tags = {
84
- album,
85
- title: clamp(text, 40),
86
- unsynchronisedLyrics: {
87
- language: isoLanguageMap[language] ?? 'eng',
88
- text
89
- }
90
- };
91
-
92
- await id3.update(tags, `${output}${id}.mp3`);
93
-
94
- if (generated) {
95
- console.log(`Voice generated for "${id}".`);
96
- } else {
97
- console.log(`Voice meta data updated for "${id}".`);
98
- }
99
- });
100
- },
101
- mapReduction = (map, list) => list.reduce((obj, file) => {
102
- const
103
- id = file.substring(0, file.length - 4);
104
-
105
- obj[id] = map[id];
106
- return obj;
107
- }, {}),
108
- TextToSpeech = class extends Cheer {
109
- constructor (data) {
110
- const
111
- {album, encodedBy} = data;
112
-
113
- super(data);
114
- this.album = album;
115
- this.encodedBy = encodedBy;
116
- }
117
-
118
- async prepare (data) {
119
- await this.replaceConfigPathWithJSON('script', 'files');
120
-
121
- // create standardized transcripts and an accompanying hash for each.
122
- const
123
- {files, voice} = this.config,
124
- standardizedFiles = {},
125
- transcripts = {};
126
-
127
- Object.keys(files).forEach((key) => {
128
- const
129
- transcript = new Transcript(files[key], {voice});
130
-
131
- standardizedFiles[key] = transcript.toJSON();
132
- transcripts[key] = transcript;
133
- });
134
- this.config.files = standardizedFiles;
135
- this.transcripts = transcripts;
136
-
137
- return super.prepare(data);
138
- }
139
-
140
- async checkDifference () {
141
- const
142
- {album, config, encodedBy = '', transcripts} = this,
143
- {output, files = {}, language, updateAllMetaData = false} = config,
144
- {missing, present, unlisted} = await getMP3s({
145
- encodedBy,
146
- fileTypes: ['mp3'],
147
- output,
148
- transcripts
149
- });
150
-
151
- config.files = mapReduction(files, missing);
152
-
153
- for (let i = 0; i < unlisted.length; i++) {
154
- await fs.rm(`${output}${unlisted[i]}`);
155
- console.log(`Removed "${unlisted[i]}"`);
156
- }
157
-
158
- if (updateAllMetaData && present.length) {
159
- appendMP3Meta({
160
- album,
161
- captions: mapReduction(files, present),
162
- language,
163
- output
164
- });
165
- }
166
-
167
- if (missing.length === 0) {
168
- throw Error('All voice-over files already exist.');
169
- }
170
-
171
- this.updateList = missing;
172
- }
173
-
174
- async afterExport (...args) {
175
- const
176
- {album, encodedBy, config, transcripts, updateList} = this,
177
- {files, output, language, voice} = config,
178
- list = updateList ?? Object.keys(files).map((id) => `${id}.mp3`);
179
-
180
- appendMP3Meta({
181
- album,
182
- captions: files,
183
- generated: true,
184
- language,
185
- output,
186
- voice
187
- });
188
- for (let i = 0; i < list.length; i++) {
189
- const
190
- filename = list[i],
191
- id = filename.substring(0, filename.length - 4),
192
- file = await getFileData(`${output}${filename}`, 'mp3');
193
-
194
- file.addHash(transcripts[id].getHash({
195
- seed: encodedBy ?? ''
196
- }), {
197
- hashKey: 'GenerationHash'
198
- });
199
- await file.save();
200
- }
201
- super.afterExport(...args);
202
- }
203
- };
204
-
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ Transcript = require('./Transcript'),
4
+ fs = require('fs').promises,
5
+ getFileData = require('../helpers/getFileData'),
6
+ {ensureDir, readdirOrEmpty} = require('../helpers/dirs'),
7
+ id3 = require('node-id3').Promise,
8
+ isoLanguageMap = {
9
+ 'en-US': 'eng',
10
+ 'es-ES': 'spa'
11
+ },
12
+ matches = (file, types) => types.reduce((prev, current) => prev || (file.slice(-(current.length + 1)) === `.${current}`), false),
13
+ getMP3s = async ({encodedBy = '', output = './', fileTypes = [], transcripts}) => {
14
+ const
15
+ all = (await readdirOrEmpty(output))
16
+ .filter((file) => (file.indexOf('.') !== 0) && (fileTypes.length === 0 || matches(file, fileTypes)));
17
+
18
+ if (transcripts) {
19
+ const
20
+ keys = Object.keys(transcripts),
21
+ list = keys.map((id) => `${id}.mp3`),
22
+ missing = [],
23
+ present = [];
24
+
25
+ // need to check for presence _and_ whether lyrics match.
26
+ for (let i = 0; i < list.length; i++) {
27
+ const
28
+ file = list[i];
29
+
30
+ if (all.indexOf(file) === -1) {
31
+ missing.push(file)
32
+ } else { // not actually missing, so let's check the artist and lyrics.
33
+ const
34
+ fileHash = (await getFileData(`${output}${file}`, 'mp3')).getHash({
35
+ hashKey: 'GenerationHash'
36
+ });
37
+
38
+ // We'll assume the original file should be kept if it doesn't include a generation hash.
39
+ if (fileHash && fileHash !== transcripts[keys[i]].getHash({
40
+ seed: encodedBy
41
+ })) {
42
+ missing.push(file);
43
+ } else {
44
+ present.push(file);
45
+ }
46
+ }
47
+ }
48
+
49
+ return {
50
+ missing,
51
+ present,
52
+ unlisted: all.filter((id) => list.indexOf(id) === -1),
53
+ all
54
+ };
55
+ }
56
+
57
+ return {
58
+ all
59
+ };
60
+ },
61
+ captionText = (caption) => (Array.isArray(caption) ? caption : [caption])
62
+ .map((cap) => cap?.caption ?? cap ?? null)
63
+ .filter((cap) => cap !== null)
64
+ .join(' '),
65
+ clamp = (text, max) => {
66
+ if (text.length <= max) {
67
+ return text;
68
+ } else {
69
+ const
70
+ arr = text.substring(0, max).split(' ');
71
+
72
+ if (arr.length > 1) {
73
+ arr.length -= 1; // remove last word which may be partial.
74
+ return `${arr.join(' ')}...`;
75
+ } else {
76
+ return arr[0];
77
+ }
78
+ }
79
+ },
80
+ appendMP3Meta = async ({album, captions, generated = false, language, output, voice}) => {
81
+ if (output) {
82
+ await ensureDir(output);
83
+ }
84
+
85
+ Object.keys(captions).forEach(async (id) => {
86
+ const
87
+ text = captionText(captions[id]),
88
+ tags = {
89
+ album,
90
+ title: clamp(text, 40),
91
+ unsynchronisedLyrics: {
92
+ language: isoLanguageMap[language] ?? 'eng',
93
+ text
94
+ }
95
+ };
96
+
97
+ await id3.update(tags, `${output}${id}.mp3`);
98
+
99
+ if (generated) {
100
+ console.log(`Voice generated for "${id}".`);
101
+ } else {
102
+ console.log(`Voice meta data updated for "${id}".`);
103
+ }
104
+ });
105
+ },
106
+ mapReduction = (map, list) => list.reduce((obj, file) => {
107
+ const
108
+ id = file.substring(0, file.length - 4);
109
+
110
+ obj[id] = map[id];
111
+ return obj;
112
+ }, {}),
113
+ TextToSpeech = class extends Cheer {
114
+ constructor (data) {
115
+ const
116
+ {album, encodedBy} = data;
117
+
118
+ super(data);
119
+ this.album = album;
120
+ this.encodedBy = encodedBy;
121
+ }
122
+
123
+ async prepare (data) {
124
+ await this.replaceConfigPathWithJSON('script', 'files');
125
+
126
+ // create standardized transcripts and an accompanying hash for each.
127
+ const
128
+ {files, voice} = this.config,
129
+ standardizedFiles = {},
130
+ transcripts = {};
131
+
132
+ Object.keys(files).forEach((key) => {
133
+ const
134
+ transcript = new Transcript(files[key], {voice});
135
+
136
+ standardizedFiles[key] = transcript.toJSON();
137
+ transcripts[key] = transcript;
138
+ });
139
+ this.config.files = standardizedFiles;
140
+ this.transcripts = transcripts;
141
+
142
+ return super.prepare(data);
143
+ }
144
+
145
+ async checkDifference () {
146
+ const
147
+ {album, config, encodedBy = '', transcripts} = this,
148
+ {output, files = {}, language, updateAllMetaData = false} = config;
149
+
150
+ if (output) {
151
+ await ensureDir(output);
152
+ }
153
+
154
+ const
155
+ {missing, present, unlisted} = await getMP3s({
156
+ encodedBy,
157
+ fileTypes: ['mp3'],
158
+ output,
159
+ transcripts
160
+ });
161
+
162
+ config.files = mapReduction(files, missing);
163
+
164
+ for (let i = 0; i < unlisted.length; i++) {
165
+ await fs.rm(`${output}${unlisted[i]}`);
166
+ console.log(`Removed "${unlisted[i]}"`);
167
+ }
168
+
169
+ if (updateAllMetaData && present.length) {
170
+ appendMP3Meta({
171
+ album,
172
+ captions: mapReduction(files, present),
173
+ language,
174
+ output
175
+ });
176
+ }
177
+
178
+ if (missing.length === 0) {
179
+ throw Error('All voice-over files already exist.');
180
+ }
181
+
182
+ this.updateList = missing;
183
+ }
184
+
185
+ async afterExport (...args) {
186
+ const
187
+ {album, encodedBy, config, transcripts, updateList} = this,
188
+ {files, output, language, voice} = config,
189
+ list = updateList ?? Object.keys(files).map((id) => `${id}.mp3`);
190
+
191
+ appendMP3Meta({
192
+ album,
193
+ captions: files,
194
+ generated: true,
195
+ language,
196
+ output,
197
+ voice
198
+ });
199
+ for (let i = 0; i < list.length; i++) {
200
+ const
201
+ filename = list[i],
202
+ id = filename.substring(0, filename.length - 4),
203
+ file = await getFileData(`${output}${filename}`, 'mp3');
204
+
205
+ file.addHash(transcripts[id].getHash({
206
+ seed: encodedBy ?? ''
207
+ }), {
208
+ hashKey: 'GenerationHash'
209
+ });
210
+ await file.save();
211
+ }
212
+ super.afterExport(...args);
213
+ }
214
+ };
215
+
205
216
  module.exports = TextToSpeech;
@@ -1,69 +1,73 @@
1
- /**
2
- * Processes and manages transcript data with voice resolution and key normalization.
3
- *
4
- * This class transforms raw transcript input into a standardized format. It ensures
5
- * every line has an assigned voice, sorts object keys for consistent hashing,
6
- * and provides utility for generating integrity hashes of the content.
7
- *
8
- * @class Transcript
9
- * @param {string|string[]|Object|Object[]} transcript - The raw dialogue or data lines.
10
- * @param {Object} options - Configuration object.
11
- * @param {string|Object} options.voice - A single voice ID or a mapping of speaker names to IDs.
12
- */
13
-
14
- const
15
- crypto = require('crypto'),
16
- sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))),
17
- resolveVoice = (voice, speaker) => {
18
- if (typeof voice === 'string') {
19
- return voice;
20
- }
21
-
22
- if (speaker && voice[speaker]) {
23
- return voice[speaker];
24
- }
25
-
26
- return voice.default ?? Object.values(voice)[0];
27
- },
28
- Transcript = class {
29
- constructor (transcript, {voice: voiceOrVoiceMap}) {
30
-
31
- this.data = (Array.isArray(transcript) ? transcript : [transcript]).map((line) => {
32
- if (typeof line === 'string') {
33
- const
34
- result = {caption: line};
35
-
36
- if (voiceOrVoiceMap) {
37
- result.voice = resolveVoice(voiceOrVoiceMap);
38
- }
39
-
40
- return result;
41
- }
42
-
43
- const
44
- {caption, speaker, voice} = line,
45
- resolvedVoice = voice ?? (voiceOrVoiceMap ? resolveVoice(voiceOrVoiceMap, speaker) : null),
46
- result = {caption};
47
-
48
- if (speaker) {
49
- result.speaker = speaker;
50
- }
51
-
52
- if (resolvedVoice) {
53
- result.voice = resolvedVoice;
54
- }
55
-
56
- return sortKeys(result);
57
- });
58
- }
59
-
60
- getHash ({seed = ''} = {}) {
61
- return crypto.createHash('sha256').update(seed + JSON.stringify(this.data)).digest('hex');
62
- }
63
-
64
- toJSON () {
65
- return this.data;
66
- }
67
- };
68
-
1
+ /**
2
+ * Processes and manages transcript data with voice resolution and key normalization.
3
+ *
4
+ * This class transforms raw transcript input into a standardized format. It ensures
5
+ * every line has an assigned voice, sorts object keys for consistent hashing,
6
+ * and provides utility for generating integrity hashes of the content.
7
+ *
8
+ * @class Transcript
9
+ * @param {string|string[]|Object|Object[]} transcript - The raw dialogue or data lines.
10
+ * @param {Object} options - Configuration object.
11
+ * @param {string|Object} options.voice - A single voice ID or a mapping of speaker names to IDs.
12
+ */
13
+
14
+ const
15
+ crypto = require('crypto'),
16
+ sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))),
17
+ resolveVoice = (voice, speaker) => {
18
+ if (typeof voice === 'string') {
19
+ return voice;
20
+ }
21
+
22
+ if (speaker && voice[speaker]) {
23
+ return voice[speaker];
24
+ }
25
+
26
+ return voice.default ?? Object.values(voice)[0];
27
+ },
28
+ Transcript = class {
29
+ constructor (transcript, {voice: voiceOrVoiceMap}) {
30
+
31
+ this.data = (Array.isArray(transcript) ? transcript : [transcript]).map((line) => {
32
+ if (typeof line === 'string') {
33
+ const
34
+ result = {caption: line};
35
+
36
+ if (voiceOrVoiceMap) {
37
+ result.voice = resolveVoice(voiceOrVoiceMap);
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ const
44
+ {caption, class: lineClass, speaker, voice} = line,
45
+ resolvedVoice = voice ?? (voiceOrVoiceMap ? resolveVoice(voiceOrVoiceMap, speaker) : null),
46
+ result = {caption};
47
+
48
+ if (lineClass) {
49
+ result.class = lineClass;
50
+ }
51
+
52
+ if (speaker) {
53
+ result.speaker = speaker;
54
+ }
55
+
56
+ if (resolvedVoice) {
57
+ result.voice = resolvedVoice;
58
+ }
59
+
60
+ return sortKeys(result);
61
+ });
62
+ }
63
+
64
+ getHash ({seed = ''} = {}) {
65
+ return crypto.createHash('sha256').update(seed + JSON.stringify(this.data)).digest('hex');
66
+ }
67
+
68
+ toJSON () {
69
+ return this.data;
70
+ }
71
+ };
72
+
69
73
  module.exports = Transcript;