retold-remote 0.0.1 → 0.0.2
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.
- package/html/index.html +2 -0
- package/package.json +20 -14
- package/source/Pict-Application-RetoldRemote.js +46 -5
- package/source/cli/RetoldRemote-CLI-Run.js +0 -0
- package/source/cli/RetoldRemote-Server-Setup.js +790 -8
- package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
- package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
- package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
- package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
- package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
- package/source/server/RetoldRemote-ArchiveService.js +830 -0
- package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
- package/source/server/RetoldRemote-EbookService.js +242 -0
- package/source/server/RetoldRemote-MediaService.js +1 -1
- package/source/server/RetoldRemote-ToolDetector.js +31 -1
- package/source/server/RetoldRemote-VideoFrameService.js +486 -0
- package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
- package/source/views/PictView-Remote-Gallery.js +141 -2
- package/source/views/PictView-Remote-Layout.js +18 -27
- package/source/views/PictView-Remote-MediaViewer.js +638 -39
- package/source/views/PictView-Remote-SettingsPanel.js +23 -0
- package/source/views/PictView-Remote-TopBar.js +121 -0
- package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
- package/web-application/index.html +2 -0
- package/web-application/js/epub.min.js +1 -0
- package/web-application/retold-remote.js +7030 -1244
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +13 -44
- package/web-application/retold-remote.min.js.map +1 -1
- package/web-application/retold-remote.compatible.js +0 -5764
- package/web-application/retold-remote.compatible.js.map +0 -1
- package/web-application/retold-remote.compatible.min.js +0 -120
- package/web-application/retold-remote.compatible.min.js.map +0 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retold Remote -- Audio Waveform Service
|
|
3
|
+
*
|
|
4
|
+
* Extracts waveform peak data and audio segments from audio/video files.
|
|
5
|
+
* Uses BBC audiowaveform when available, falls back to ffprobe/ffmpeg.
|
|
6
|
+
* Results are cached so repeated requests are instant.
|
|
7
|
+
*
|
|
8
|
+
* API:
|
|
9
|
+
* extractWaveform(pAbsPath, pRelPath, pOptions, fCallback)
|
|
10
|
+
* -> { Peaks: [{ Min, Max }], Duration, SampleRate, Channels, ... }
|
|
11
|
+
*
|
|
12
|
+
* extractSegment(pAbsPath, pRelPath, pOptions, fCallback)
|
|
13
|
+
* -> { SegmentPath, Duration, Start, End, Format, CacheKey, Filename }
|
|
14
|
+
*
|
|
15
|
+
* @license MIT
|
|
16
|
+
*/
|
|
17
|
+
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
18
|
+
const libFs = require('fs');
|
|
19
|
+
const libPath = require('path');
|
|
20
|
+
const libCrypto = require('crypto');
|
|
21
|
+
const libChildProcess = require('child_process');
|
|
22
|
+
|
|
23
|
+
const _DefaultServiceConfiguration =
|
|
24
|
+
{
|
|
25
|
+
"ContentPath": ".",
|
|
26
|
+
"CachePath": null,
|
|
27
|
+
"DefaultPeakCount": 2000,
|
|
28
|
+
"DefaultSegmentFormat": "mp3",
|
|
29
|
+
"MaxSegmentDuration": 600
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
class RetoldRemoteAudioWaveformService extends libFableServiceProviderBase
|
|
33
|
+
{
|
|
34
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
35
|
+
{
|
|
36
|
+
super(pFable, pOptions, pServiceHash);
|
|
37
|
+
|
|
38
|
+
this.serviceType = 'RetoldRemoteAudioWaveformService';
|
|
39
|
+
|
|
40
|
+
// Merge with defaults
|
|
41
|
+
for (let tmpKey in _DefaultServiceConfiguration)
|
|
42
|
+
{
|
|
43
|
+
if (!(tmpKey in this.options))
|
|
44
|
+
{
|
|
45
|
+
this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.contentPath = libPath.resolve(this.options.ContentPath);
|
|
50
|
+
|
|
51
|
+
this.cachePath = this.options.CachePath
|
|
52
|
+
|| libPath.join(process.cwd(), 'dist', 'retold-cache', 'audio-waveforms');
|
|
53
|
+
|
|
54
|
+
// Ensure cache directory exists
|
|
55
|
+
if (!libFs.existsSync(this.cachePath))
|
|
56
|
+
{
|
|
57
|
+
libFs.mkdirSync(this.cachePath, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Detect audiowaveform availability
|
|
61
|
+
this.hasAudiowaveform = this._detectCommand('audiowaveform --version');
|
|
62
|
+
|
|
63
|
+
this.fable.log.info(`Audio Waveform Service: cache at ${this.cachePath}`);
|
|
64
|
+
this.fable.log.info(` audiowaveform tool: ${this.hasAudiowaveform ? 'available' : 'not found (using ffprobe fallback)'}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if a command-line tool is available.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} pCommand - The command to test
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
_detectCommand(pCommand)
|
|
74
|
+
{
|
|
75
|
+
try
|
|
76
|
+
{
|
|
77
|
+
libChildProcess.execSync(pCommand, { stdio: 'ignore', timeout: 5000 });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch (pError)
|
|
81
|
+
{
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the cache directory for a specific audio file's waveform.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
90
|
+
* @param {number} pMtimeMs - Modification time in ms
|
|
91
|
+
* @param {number} pPeakCount - Number of peaks requested
|
|
92
|
+
* @returns {string} Absolute path to the cache directory
|
|
93
|
+
*/
|
|
94
|
+
_getWaveformCacheDir(pAbsPath, pMtimeMs, pPeakCount)
|
|
95
|
+
{
|
|
96
|
+
let tmpInput = `waveform:${pAbsPath}:${pMtimeMs}:${pPeakCount}`;
|
|
97
|
+
let tmpHash = libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
|
|
98
|
+
return libPath.join(this.cachePath, tmpHash);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the cache directory for an extracted audio segment.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
105
|
+
* @param {number} pMtimeMs - Modification time in ms
|
|
106
|
+
* @param {number} pStart - Start time in seconds
|
|
107
|
+
* @param {number} pEnd - End time in seconds
|
|
108
|
+
* @param {string} pFormat - Output format
|
|
109
|
+
* @returns {string} Absolute path to the cache directory
|
|
110
|
+
*/
|
|
111
|
+
_getSegmentCacheDir(pAbsPath, pMtimeMs, pStart, pEnd, pFormat)
|
|
112
|
+
{
|
|
113
|
+
let tmpInput = `segment:${pAbsPath}:${pMtimeMs}:${pStart}:${pEnd}:${pFormat}`;
|
|
114
|
+
let tmpHash = libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
|
|
115
|
+
return libPath.join(this.cachePath, tmpHash);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Probe an audio file with ffprobe to get metadata.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
122
|
+
* @param {Function} fCallback - Callback(pError, { duration, sampleRate, channels, codec, bitrate, size })
|
|
123
|
+
*/
|
|
124
|
+
_probeAudio(pAbsPath, fCallback)
|
|
125
|
+
{
|
|
126
|
+
try
|
|
127
|
+
{
|
|
128
|
+
let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pAbsPath}"`;
|
|
129
|
+
let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 15000 });
|
|
130
|
+
let tmpData = JSON.parse(tmpOutput.toString());
|
|
131
|
+
|
|
132
|
+
let tmpResult = {};
|
|
133
|
+
|
|
134
|
+
if (tmpData.format)
|
|
135
|
+
{
|
|
136
|
+
tmpResult.duration = parseFloat(tmpData.format.duration) || null;
|
|
137
|
+
tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
|
|
138
|
+
tmpResult.size = parseInt(tmpData.format.size, 10) || null;
|
|
139
|
+
tmpResult.formatName = tmpData.format.format_name || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (tmpData.streams)
|
|
143
|
+
{
|
|
144
|
+
for (let i = 0; i < tmpData.streams.length; i++)
|
|
145
|
+
{
|
|
146
|
+
let tmpStream = tmpData.streams[i];
|
|
147
|
+
if (tmpStream.codec_type === 'audio')
|
|
148
|
+
{
|
|
149
|
+
tmpResult.sampleRate = parseInt(tmpStream.sample_rate, 10) || null;
|
|
150
|
+
tmpResult.channels = tmpStream.channels || null;
|
|
151
|
+
tmpResult.codec = tmpStream.codec_name || null;
|
|
152
|
+
tmpResult.channelLayout = tmpStream.channel_layout || null;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return fCallback(null, tmpResult);
|
|
159
|
+
}
|
|
160
|
+
catch (pError)
|
|
161
|
+
{
|
|
162
|
+
return fCallback(pError);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract waveform peaks using BBC audiowaveform tool.
|
|
168
|
+
* Generates a JSON file with min/max peak pairs.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
171
|
+
* @param {number} pPeakCount - Desired number of peak data points
|
|
172
|
+
* @param {string} pOutputPath - Path for the output JSON file
|
|
173
|
+
* @param {number} pDuration - Duration of the audio in seconds
|
|
174
|
+
* @param {Function} fCallback - Callback(pError, pPeaksArray)
|
|
175
|
+
*/
|
|
176
|
+
_extractWithAudiowaveform(pAbsPath, pPeakCount, pOutputPath, pDuration, fCallback)
|
|
177
|
+
{
|
|
178
|
+
try
|
|
179
|
+
{
|
|
180
|
+
// audiowaveform uses pixels-per-second, so we calculate from desired peak count
|
|
181
|
+
// and the duration. Each "pixel" gives us a min/max pair.
|
|
182
|
+
let tmpSamplesPerPixel = Math.max(1, Math.round((pDuration * 44100) / pPeakCount));
|
|
183
|
+
|
|
184
|
+
// audiowaveform requires specific input formats; for unsupported formats we
|
|
185
|
+
// pipe through ffmpeg first.
|
|
186
|
+
let tmpExt = libPath.extname(pAbsPath).toLowerCase();
|
|
187
|
+
let tmpNativeFormats = { '.wav': true, '.mp3': true, '.flac': true, '.ogg': true };
|
|
188
|
+
|
|
189
|
+
let tmpCmd;
|
|
190
|
+
if (tmpNativeFormats[tmpExt])
|
|
191
|
+
{
|
|
192
|
+
tmpCmd = `audiowaveform -i "${pAbsPath}" -o "${pOutputPath}" --pixels-per-second ${Math.max(1, Math.round(pPeakCount / pDuration))} -b 8`;
|
|
193
|
+
}
|
|
194
|
+
else
|
|
195
|
+
{
|
|
196
|
+
// Pipe through ffmpeg to convert to wav first
|
|
197
|
+
tmpCmd = `ffmpeg -i "${pAbsPath}" -f wav -ac 1 -ar 44100 pipe:1 2>/dev/null | audiowaveform -i - --input-format wav -o "${pOutputPath}" --pixels-per-second ${Math.max(1, Math.round(pPeakCount / pDuration))} -b 8`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
libChildProcess.execSync(tmpCmd, { stdio: ['pipe', 'pipe', 'ignore'], timeout: 120000, maxBuffer: 10 * 1024 * 1024 });
|
|
201
|
+
|
|
202
|
+
if (!libFs.existsSync(pOutputPath))
|
|
203
|
+
{
|
|
204
|
+
return fCallback(new Error('audiowaveform did not produce output.'));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse the audiowaveform JSON output
|
|
208
|
+
let tmpRawData = JSON.parse(libFs.readFileSync(pOutputPath, 'utf8'));
|
|
209
|
+
let tmpData = tmpRawData.data || [];
|
|
210
|
+
|
|
211
|
+
// audiowaveform outputs interleaved min/max values
|
|
212
|
+
let tmpPeaks = [];
|
|
213
|
+
for (let i = 0; i < tmpData.length - 1; i += 2)
|
|
214
|
+
{
|
|
215
|
+
tmpPeaks.push(
|
|
216
|
+
{
|
|
217
|
+
Min: tmpData[i] / 128, // Normalize from -128..127 to -1..1
|
|
218
|
+
Max: tmpData[i + 1] / 128
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return fCallback(null, tmpPeaks);
|
|
223
|
+
}
|
|
224
|
+
catch (pError)
|
|
225
|
+
{
|
|
226
|
+
return fCallback(pError);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extract waveform peaks using ffprobe with the astats filter.
|
|
232
|
+
* This is the fallback when audiowaveform is not available.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
235
|
+
* @param {number} pPeakCount - Desired number of peak data points
|
|
236
|
+
* @param {number} pDuration - Duration in seconds
|
|
237
|
+
* @param {Function} fCallback - Callback(pError, pPeaksArray)
|
|
238
|
+
*/
|
|
239
|
+
_extractWithFfprobe(pAbsPath, pPeakCount, pDuration, fCallback)
|
|
240
|
+
{
|
|
241
|
+
try
|
|
242
|
+
{
|
|
243
|
+
// Calculate the chunk size (number of samples per peak)
|
|
244
|
+
// Default sample rate assumption: 44100
|
|
245
|
+
let tmpSampleRate = 44100;
|
|
246
|
+
let tmpTotalSamples = Math.round(pDuration * tmpSampleRate);
|
|
247
|
+
let tmpSamplesPerChunk = Math.max(1, Math.round(tmpTotalSamples / pPeakCount));
|
|
248
|
+
|
|
249
|
+
// Use ffmpeg to output raw peak levels as a series of volume measurements
|
|
250
|
+
// The showvolume or volumedetect filter is too coarse; instead we use
|
|
251
|
+
// the astats filter with frame-based analysis via ffprobe.
|
|
252
|
+
let tmpCmd = `ffprobe -f lavfi -i "amovie='${pAbsPath.replace(/'/g, "'\\''")}',asetnsamples=${tmpSamplesPerChunk},astats=metadata=1:reset=1" -show_entries frame_tags=lavfi.astats.Overall.Peak_level,lavfi.astats.Overall.RMS_level -of json -v quiet`;
|
|
253
|
+
|
|
254
|
+
let tmpOutput = libChildProcess.execSync(tmpCmd,
|
|
255
|
+
{
|
|
256
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
257
|
+
timeout: 120000
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
let tmpData = JSON.parse(tmpOutput.toString());
|
|
261
|
+
let tmpFrames = tmpData.frames || [];
|
|
262
|
+
|
|
263
|
+
let tmpPeaks = [];
|
|
264
|
+
for (let i = 0; i < tmpFrames.length; i++)
|
|
265
|
+
{
|
|
266
|
+
let tmpTags = tmpFrames[i].tags || {};
|
|
267
|
+
let tmpPeakLevel = parseFloat(tmpTags['lavfi.astats.Overall.Peak_level']);
|
|
268
|
+
|
|
269
|
+
if (isNaN(tmpPeakLevel) || tmpPeakLevel === -Infinity || tmpPeakLevel < -120)
|
|
270
|
+
{
|
|
271
|
+
tmpPeaks.push({ Min: 0, Max: 0 });
|
|
272
|
+
}
|
|
273
|
+
else
|
|
274
|
+
{
|
|
275
|
+
// Convert dB to linear (0..1 range)
|
|
276
|
+
let tmpLinear = Math.pow(10, tmpPeakLevel / 20);
|
|
277
|
+
tmpLinear = Math.min(1.0, tmpLinear);
|
|
278
|
+
tmpPeaks.push(
|
|
279
|
+
{
|
|
280
|
+
Min: -tmpLinear,
|
|
281
|
+
Max: tmpLinear
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// If we got no peaks, try a simpler approach
|
|
287
|
+
if (tmpPeaks.length === 0)
|
|
288
|
+
{
|
|
289
|
+
return this._extractWithFfmpegFallback(pAbsPath, pPeakCount, pDuration, fCallback);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return fCallback(null, tmpPeaks);
|
|
293
|
+
}
|
|
294
|
+
catch (pError)
|
|
295
|
+
{
|
|
296
|
+
// Try simpler fallback
|
|
297
|
+
return this._extractWithFfmpegFallback(pAbsPath, pPeakCount, pDuration, fCallback);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Simplest fallback: use ffmpeg to generate a WAV representation and
|
|
303
|
+
* read raw PCM values directly. Works when ffprobe lavfi fails.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
306
|
+
* @param {number} pPeakCount - Desired number of peak data points
|
|
307
|
+
* @param {number} pDuration - Duration in seconds
|
|
308
|
+
* @param {Function} fCallback - Callback(pError, pPeaksArray)
|
|
309
|
+
*/
|
|
310
|
+
_extractWithFfmpegFallback(pAbsPath, pPeakCount, pDuration, fCallback)
|
|
311
|
+
{
|
|
312
|
+
try
|
|
313
|
+
{
|
|
314
|
+
// Downsample to a low sample rate to reduce data; mono 8-bit signed
|
|
315
|
+
let tmpTargetRate = Math.max(100, Math.round(pPeakCount / pDuration));
|
|
316
|
+
// Use a maximum rate to avoid oversized buffers
|
|
317
|
+
tmpTargetRate = Math.min(tmpTargetRate, 8000);
|
|
318
|
+
|
|
319
|
+
let tmpCmd = `ffmpeg -i "${pAbsPath}" -ac 1 -ar ${tmpTargetRate} -f s16le -acodec pcm_s16le pipe:1 2>/dev/null`;
|
|
320
|
+
|
|
321
|
+
let tmpBuffer = libChildProcess.execSync(tmpCmd,
|
|
322
|
+
{
|
|
323
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
324
|
+
timeout: 120000
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Each sample is 2 bytes (16-bit signed)
|
|
328
|
+
let tmpTotalSamples = tmpBuffer.length / 2;
|
|
329
|
+
let tmpSamplesPerPeak = Math.max(1, Math.round(tmpTotalSamples / pPeakCount));
|
|
330
|
+
|
|
331
|
+
let tmpPeaks = [];
|
|
332
|
+
for (let i = 0; i < tmpTotalSamples; i += tmpSamplesPerPeak)
|
|
333
|
+
{
|
|
334
|
+
let tmpMin = 0;
|
|
335
|
+
let tmpMax = 0;
|
|
336
|
+
let tmpEnd = Math.min(i + tmpSamplesPerPeak, tmpTotalSamples);
|
|
337
|
+
|
|
338
|
+
for (let j = i; j < tmpEnd; j++)
|
|
339
|
+
{
|
|
340
|
+
let tmpSample = tmpBuffer.readInt16LE(j * 2);
|
|
341
|
+
let tmpNorm = tmpSample / 32768;
|
|
342
|
+
if (tmpNorm < tmpMin) tmpMin = tmpNorm;
|
|
343
|
+
if (tmpNorm > tmpMax) tmpMax = tmpNorm;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
tmpPeaks.push({ Min: tmpMin, Max: tmpMax });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (tmpPeaks.length === 0)
|
|
350
|
+
{
|
|
351
|
+
return fCallback(new Error('Could not extract waveform data.'));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return fCallback(null, tmpPeaks);
|
|
355
|
+
}
|
|
356
|
+
catch (pError)
|
|
357
|
+
{
|
|
358
|
+
return fCallback(pError);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Format a timestamp in seconds to a human-readable string.
|
|
364
|
+
*
|
|
365
|
+
* @param {number} pSeconds - Timestamp in seconds
|
|
366
|
+
* @returns {string} Formatted string like "1:23:45" or "12:34"
|
|
367
|
+
*/
|
|
368
|
+
_formatTimestamp(pSeconds)
|
|
369
|
+
{
|
|
370
|
+
let tmpHours = Math.floor(pSeconds / 3600);
|
|
371
|
+
let tmpMinutes = Math.floor((pSeconds % 3600) / 60);
|
|
372
|
+
let tmpSecs = Math.floor(pSeconds % 60);
|
|
373
|
+
|
|
374
|
+
if (tmpHours > 0)
|
|
375
|
+
{
|
|
376
|
+
return `${tmpHours}:${String(tmpMinutes).padStart(2, '0')}:${String(tmpSecs).padStart(2, '0')}`;
|
|
377
|
+
}
|
|
378
|
+
return `${tmpMinutes}:${String(tmpSecs).padStart(2, '0')}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Extract waveform peak data from an audio file.
|
|
383
|
+
* Results are cached for fast repeated access.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
386
|
+
* @param {string} pRelPath - Relative path (for the response)
|
|
387
|
+
* @param {object} pOptions - { peaks }
|
|
388
|
+
* @param {Function} fCallback - Callback(pError, pResult)
|
|
389
|
+
*/
|
|
390
|
+
extractWaveform(pAbsPath, pRelPath, pOptions, fCallback)
|
|
391
|
+
{
|
|
392
|
+
let tmpSelf = this;
|
|
393
|
+
let tmpPeakCount = parseInt(pOptions.peaks, 10) || this.options.DefaultPeakCount;
|
|
394
|
+
|
|
395
|
+
// Clamp peak count
|
|
396
|
+
tmpPeakCount = Math.min(Math.max(tmpPeakCount, 100), 10000);
|
|
397
|
+
|
|
398
|
+
// Get file stats for cache key
|
|
399
|
+
let tmpStat;
|
|
400
|
+
try
|
|
401
|
+
{
|
|
402
|
+
tmpStat = libFs.statSync(pAbsPath);
|
|
403
|
+
}
|
|
404
|
+
catch (pError)
|
|
405
|
+
{
|
|
406
|
+
return fCallback(new Error('File not found.'));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let tmpCacheDir = this._getWaveformCacheDir(pAbsPath, tmpStat.mtimeMs, tmpPeakCount);
|
|
410
|
+
|
|
411
|
+
// Check for cached manifest
|
|
412
|
+
let tmpManifestPath = libPath.join(tmpCacheDir, 'manifest.json');
|
|
413
|
+
if (libFs.existsSync(tmpManifestPath))
|
|
414
|
+
{
|
|
415
|
+
try
|
|
416
|
+
{
|
|
417
|
+
let tmpManifest = JSON.parse(libFs.readFileSync(tmpManifestPath, 'utf8'));
|
|
418
|
+
this.fable.log.info(`Audio waveform cache hit for ${pRelPath}`);
|
|
419
|
+
return fCallback(null, tmpManifest);
|
|
420
|
+
}
|
|
421
|
+
catch (pError)
|
|
422
|
+
{
|
|
423
|
+
// Corrupted manifest, regenerate
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Probe the audio for metadata
|
|
428
|
+
this._probeAudio(pAbsPath,
|
|
429
|
+
(pError, pAudioInfo) =>
|
|
430
|
+
{
|
|
431
|
+
if (pError || !pAudioInfo || !pAudioInfo.duration)
|
|
432
|
+
{
|
|
433
|
+
return fCallback(new Error('Could not probe audio file. ffprobe may not be available.'));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let tmpDuration = pAudioInfo.duration;
|
|
437
|
+
|
|
438
|
+
// Ensure cache directory exists
|
|
439
|
+
if (!libFs.existsSync(tmpCacheDir))
|
|
440
|
+
{
|
|
441
|
+
libFs.mkdirSync(tmpCacheDir, { recursive: true });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
tmpSelf.fable.log.info(`Extracting waveform for ${pRelPath} (${tmpDuration.toFixed(1)}s, ${tmpPeakCount} peaks)`);
|
|
445
|
+
|
|
446
|
+
let tmpExtractCallback = (pExtractError, pPeaks) =>
|
|
447
|
+
{
|
|
448
|
+
if (pExtractError || !pPeaks || pPeaks.length === 0)
|
|
449
|
+
{
|
|
450
|
+
return fCallback(new Error('Failed to extract waveform data: ' + (pExtractError ? pExtractError.message : 'no peaks generated')));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let tmpResult =
|
|
454
|
+
{
|
|
455
|
+
Success: true,
|
|
456
|
+
Path: pRelPath,
|
|
457
|
+
Duration: tmpDuration,
|
|
458
|
+
DurationFormatted: tmpSelf._formatTimestamp(tmpDuration),
|
|
459
|
+
SampleRate: pAudioInfo.sampleRate,
|
|
460
|
+
Channels: pAudioInfo.channels,
|
|
461
|
+
ChannelLayout: pAudioInfo.channelLayout,
|
|
462
|
+
Codec: pAudioInfo.codec,
|
|
463
|
+
Bitrate: pAudioInfo.bitrate,
|
|
464
|
+
FileSize: pAudioInfo.size || tmpStat.size,
|
|
465
|
+
FormatName: pAudioInfo.formatName,
|
|
466
|
+
PeakCount: pPeaks.length,
|
|
467
|
+
RequestedPeaks: tmpPeakCount,
|
|
468
|
+
Method: tmpSelf.hasAudiowaveform ? 'audiowaveform' : 'ffmpeg',
|
|
469
|
+
CacheKey: libPath.basename(tmpCacheDir),
|
|
470
|
+
Peaks: pPeaks
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// Write manifest to cache
|
|
474
|
+
try
|
|
475
|
+
{
|
|
476
|
+
libFs.writeFileSync(tmpManifestPath, JSON.stringify(tmpResult, null, '\t'));
|
|
477
|
+
}
|
|
478
|
+
catch (pWriteError)
|
|
479
|
+
{
|
|
480
|
+
tmpSelf.fable.log.warn(`Could not write waveform manifest: ${pWriteError.message}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
tmpSelf.fable.log.info(`Extracted ${pPeaks.length} peaks for ${pRelPath} (${tmpResult.Method})`);
|
|
484
|
+
return fCallback(null, tmpResult);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// Use audiowaveform if available, otherwise fall back to ffprobe/ffmpeg
|
|
488
|
+
if (tmpSelf.hasAudiowaveform)
|
|
489
|
+
{
|
|
490
|
+
let tmpAwfOutputPath = libPath.join(tmpCacheDir, 'waveform.json');
|
|
491
|
+
tmpSelf._extractWithAudiowaveform(pAbsPath, tmpPeakCount, tmpAwfOutputPath, tmpDuration, tmpExtractCallback);
|
|
492
|
+
}
|
|
493
|
+
else
|
|
494
|
+
{
|
|
495
|
+
tmpSelf._extractWithFfprobe(pAbsPath, tmpPeakCount, tmpDuration, tmpExtractCallback);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Extract an audio segment (sub-clip) from a file.
|
|
502
|
+
* Uses stream copy when possible for lossless & fast extraction.
|
|
503
|
+
* Results are cached.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} pAbsPath - Absolute path to the audio file
|
|
506
|
+
* @param {string} pRelPath - Relative path (for the response)
|
|
507
|
+
* @param {object} pOptions - { start, end, format }
|
|
508
|
+
* @param {Function} fCallback - Callback(pError, pResult)
|
|
509
|
+
*/
|
|
510
|
+
extractSegment(pAbsPath, pRelPath, pOptions, fCallback)
|
|
511
|
+
{
|
|
512
|
+
let tmpSelf = this;
|
|
513
|
+
let tmpStart = parseFloat(pOptions.start) || 0;
|
|
514
|
+
let tmpEnd = parseFloat(pOptions.end) || 0;
|
|
515
|
+
let tmpFormat = pOptions.format || this.options.DefaultSegmentFormat;
|
|
516
|
+
|
|
517
|
+
// Validate format
|
|
518
|
+
let tmpValidFormats = { 'mp3': true, 'aac': true, 'ogg': true, 'wav': true, 'flac': true };
|
|
519
|
+
if (!tmpValidFormats[tmpFormat])
|
|
520
|
+
{
|
|
521
|
+
tmpFormat = 'mp3';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (tmpEnd <= tmpStart)
|
|
525
|
+
{
|
|
526
|
+
return fCallback(new Error('End time must be greater than start time.'));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let tmpDuration = tmpEnd - tmpStart;
|
|
530
|
+
if (tmpDuration > this.options.MaxSegmentDuration)
|
|
531
|
+
{
|
|
532
|
+
return fCallback(new Error(`Segment too long. Maximum is ${this.options.MaxSegmentDuration} seconds.`));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Get file stats for cache key
|
|
536
|
+
let tmpStat;
|
|
537
|
+
try
|
|
538
|
+
{
|
|
539
|
+
tmpStat = libFs.statSync(pAbsPath);
|
|
540
|
+
}
|
|
541
|
+
catch (pError)
|
|
542
|
+
{
|
|
543
|
+
return fCallback(new Error('File not found.'));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let tmpCacheDir = this._getSegmentCacheDir(pAbsPath, tmpStat.mtimeMs, tmpStart, tmpEnd, tmpFormat);
|
|
547
|
+
|
|
548
|
+
// Check for cached segment
|
|
549
|
+
let tmpFilename = `segment.${tmpFormat}`;
|
|
550
|
+
let tmpSegmentPath = libPath.join(tmpCacheDir, tmpFilename);
|
|
551
|
+
if (libFs.existsSync(tmpSegmentPath))
|
|
552
|
+
{
|
|
553
|
+
this.fable.log.info(`Audio segment cache hit for ${pRelPath} [${tmpStart}-${tmpEnd}]`);
|
|
554
|
+
return fCallback(null,
|
|
555
|
+
{
|
|
556
|
+
Success: true,
|
|
557
|
+
SegmentPath: tmpSegmentPath,
|
|
558
|
+
CacheKey: libPath.basename(tmpCacheDir),
|
|
559
|
+
Filename: tmpFilename,
|
|
560
|
+
Start: tmpStart,
|
|
561
|
+
End: tmpEnd,
|
|
562
|
+
Duration: tmpDuration,
|
|
563
|
+
Format: tmpFormat
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Ensure cache directory exists
|
|
568
|
+
if (!libFs.existsSync(tmpCacheDir))
|
|
569
|
+
{
|
|
570
|
+
libFs.mkdirSync(tmpCacheDir, { recursive: true });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this.fable.log.info(`Extracting audio segment from ${pRelPath} [${tmpStart.toFixed(1)}s - ${tmpEnd.toFixed(1)}s]`);
|
|
574
|
+
|
|
575
|
+
try
|
|
576
|
+
{
|
|
577
|
+
// Format timestamps for ffmpeg
|
|
578
|
+
let tmpStartStr = tmpStart.toFixed(3);
|
|
579
|
+
let tmpDurationStr = tmpDuration.toFixed(3);
|
|
580
|
+
|
|
581
|
+
// Map format to ffmpeg codec options
|
|
582
|
+
let tmpCodecArgs;
|
|
583
|
+
switch (tmpFormat)
|
|
584
|
+
{
|
|
585
|
+
case 'wav':
|
|
586
|
+
tmpCodecArgs = '-c:a pcm_s16le';
|
|
587
|
+
break;
|
|
588
|
+
case 'flac':
|
|
589
|
+
tmpCodecArgs = '-c:a flac';
|
|
590
|
+
break;
|
|
591
|
+
case 'ogg':
|
|
592
|
+
tmpCodecArgs = '-c:a libvorbis -q:a 5';
|
|
593
|
+
break;
|
|
594
|
+
case 'aac':
|
|
595
|
+
tmpCodecArgs = '-c:a aac -b:a 192k';
|
|
596
|
+
break;
|
|
597
|
+
case 'mp3':
|
|
598
|
+
default:
|
|
599
|
+
tmpCodecArgs = '-c:a libmp3lame -q:a 2';
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let tmpCmd = `ffmpeg -ss ${tmpStartStr} -t ${tmpDurationStr} -i "${pAbsPath}" -vn ${tmpCodecArgs} -y "${tmpSegmentPath}"`;
|
|
604
|
+
libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 60000 });
|
|
605
|
+
|
|
606
|
+
if (!libFs.existsSync(tmpSegmentPath))
|
|
607
|
+
{
|
|
608
|
+
return fCallback(new Error('Failed to extract audio segment.'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.fable.log.info(`Extracted audio segment for ${pRelPath} [${tmpStart.toFixed(1)}s - ${tmpEnd.toFixed(1)}s]`);
|
|
612
|
+
|
|
613
|
+
return fCallback(null,
|
|
614
|
+
{
|
|
615
|
+
Success: true,
|
|
616
|
+
SegmentPath: tmpSegmentPath,
|
|
617
|
+
CacheKey: libPath.basename(tmpCacheDir),
|
|
618
|
+
Filename: tmpFilename,
|
|
619
|
+
Start: tmpStart,
|
|
620
|
+
End: tmpEnd,
|
|
621
|
+
Duration: tmpDuration,
|
|
622
|
+
Format: tmpFormat
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch (pError)
|
|
626
|
+
{
|
|
627
|
+
this.fable.log.warn(`Audio segment extraction failed: ${pError.message}`);
|
|
628
|
+
return fCallback(new Error('Failed to extract audio segment.'));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Get the absolute path to a cached segment file.
|
|
634
|
+
*
|
|
635
|
+
* @param {string} pCacheKey - The cache key (directory name)
|
|
636
|
+
* @param {string} pFilename - The segment filename
|
|
637
|
+
* @returns {string|null} Absolute path or null if not found
|
|
638
|
+
*/
|
|
639
|
+
getSegmentPath(pCacheKey, pFilename)
|
|
640
|
+
{
|
|
641
|
+
// Sanitize inputs to prevent directory traversal
|
|
642
|
+
if (!pCacheKey || !pFilename)
|
|
643
|
+
{
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
if (pCacheKey.includes('..') || pCacheKey.includes('/') || pCacheKey.includes('\\'))
|
|
647
|
+
{
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
if (pFilename.includes('..') || pFilename.includes('/') || pFilename.includes('\\'))
|
|
651
|
+
{
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
let tmpPath = libPath.join(this.cachePath, pCacheKey, pFilename);
|
|
656
|
+
|
|
657
|
+
// Double-check it's under our cache dir
|
|
658
|
+
let tmpResolved = libPath.resolve(tmpPath);
|
|
659
|
+
if (!tmpResolved.startsWith(this.cachePath))
|
|
660
|
+
{
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (libFs.existsSync(tmpPath))
|
|
665
|
+
{
|
|
666
|
+
return tmpPath;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
module.exports = RetoldRemoteAudioWaveformService;
|