From c802445f282e3cc66a8f525c8d6da31a0576804f Mon Sep 17 00:00:00 2001 From: Chris Hubbard Date: Thu, 3 Aug 2023 08:49:59 -0400 Subject: [PATCH] Initial start at sharing audio --- package-lock.json | 53 ++++++++++++++++++++++ package.json | 4 +- src/lib/data/audio-convert.ts | 63 ++++++++++++++++++++++++++ src/lib/data/audio.ts | 83 +++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/lib/data/audio-convert.ts diff --git a/package-lock.json b/package-lock.json index dc7c516b7..3f0f277b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "@ffmpeg/ffmpeg": "^0.12.1", + "@ffmpeg/util": "^0.12.0", "@sveltejs/adapter-static": "^2", "@sveltejs/kit": "^1", "@tailwindcss/typography": "^0.5.2", @@ -1027,6 +1029,36 @@ "integrity": "sha512-iL0PIMwejpmuVHgfibHpfDwOdsbmB50wr21X71VnF5d7SsBF7WK+ZvP/SCcFm7Iwb9iiYSap9rlrdhToNAWdxg==", "dev": true }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.1.tgz", + "integrity": "sha512-aKg35z/a/DJIZNtfYHsKd+oJwoQAahU+H8V+TI4MJI8ewMwfyLpnQoD0bqAuLI4ZePORppDjv1CgUyvmJagI4g==", + "dev": true, + "dependencies": { + "@ffmpeg/types": "^0.12.0" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.0.tgz", + "integrity": "sha512-AuR4K+L6v1/9hVOsikU4rGGT5nKulQa8HrtYhpgBEq0HojoWB1c9bq3TTkNBpEvS/gC17WDMVJrqIGgXOj1DXA==", + "dev": true, + "engines": { + "node": ">=16.6.0" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.0.tgz", + "integrity": "sha512-8sHCW8H/ngqVhbRvCCX4e4uDNgZVoz8uNRry1zphIyzdX6flfBa2TmVwJ4g9/Qw//eAubEnuHaSxDGWWxcOTjg==", + "dev": true, + "engines": { + "node": ">=18.17.0" + } + }, "node_modules/@graphql-tools/merge": { "version": "8.3.18", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.3.18.tgz", @@ -9446,6 +9478,27 @@ } } }, + "@ffmpeg/ffmpeg": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.1.tgz", + "integrity": "sha512-aKg35z/a/DJIZNtfYHsKd+oJwoQAahU+H8V+TI4MJI8ewMwfyLpnQoD0bqAuLI4ZePORppDjv1CgUyvmJagI4g==", + "dev": true, + "requires": { + "@ffmpeg/types": "^0.12.0" + } + }, + "@ffmpeg/types": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.0.tgz", + "integrity": "sha512-AuR4K+L6v1/9hVOsikU4rGGT5nKulQa8HrtYhpgBEq0HojoWB1c9bq3TTkNBpEvS/gC17WDMVJrqIGgXOj1DXA==", + "dev": true + }, + "@ffmpeg/util": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.0.tgz", + "integrity": "sha512-8sHCW8H/ngqVhbRvCCX4e4uDNgZVoz8uNRry1zphIyzdX6flfBa2TmVwJ4g9/Qw//eAubEnuHaSxDGWWxcOTjg==", + "dev": true + }, "@graphql-tools/merge": { "version": "8.3.18", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.3.18.tgz", diff --git a/package.json b/package.json index 5e6042c21..6a822fc02 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "@ffmpeg/ffmpeg": "^0.12.1", + "@ffmpeg/util": "^0.12.0", "@sveltejs/adapter-static": "^2", "@sveltejs/kit": "^1", "@tailwindcss/typography": "^0.5.2", @@ -60,6 +62,6 @@ "vite": "^4" }, "volta": { - "node": "16.17.0" + "node": "18.17.0" } } diff --git a/src/lib/data/audio-convert.ts b/src/lib/data/audio-convert.ts new file mode 100644 index 000000000..d307cd028 --- /dev/null +++ b/src/lib/data/audio-convert.ts @@ -0,0 +1,63 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import type { FileData } from '@ffmpeg/ffmpeg/dist/esm/types'; +import { toBlobURL, fetchFile } from '@ffmpeg/util'; + +// +//-y -i /data/user/0/com.example.app.scripture/cache/B01___01_Matthew_____ENGWEBN2DA.mp3 +//-map_metadata 0 -ss 00:00:20.120 -to 00:00:28.960 +//-map 0:a -acodec copy -write_xing 0 +// "/storage/emulated/0/Android/data/com.example.app.scripture/files/WEB Gospels/Matthew_1_3.mp3" + +let ffmpeg: FFmpeg = null; +let loaded = false; +export async function loadFFmpeg() { + if (!ffmpeg) { + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.1/dist/umd'; + ffmpeg = new FFmpeg(); + ffmpeg.on('log', ({ message }) => { + console.log(message); + }); + // toBlobURL is used to bypass CORS issue, urls with the same + // domain can be used directly. + await ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm') + }); + loaded = true; + } +} + +export async function convert( + inputPath: string, + timingStart: string, + timingEnd: string, + output: string +): Promise { + if (!loaded) { + await loadFFmpeg(); + } + + const input = 'input.' + inputPath.split('.').pop(); + await ffmpeg.writeFile(input, await fetchFile(inputPath)); + await ffmpeg.exec([ + '-i', + input, + '-map_metadata', + '0', + '-ss', + timingStart, + '-to', + timingEnd, + '-map', + '0:a', + '-acodec', + 'copy', + '-write_xing', + '0', + output + ]); + const data = await ffmpeg.readFile(output); + await ffmpeg.deleteFile(input); + await ffmpeg.deleteFile(output); + return data; +} diff --git a/src/lib/data/audio.ts b/src/lib/data/audio.ts index 2785b85de..05e77654f 100644 --- a/src/lib/data/audio.ts +++ b/src/lib/data/audio.ts @@ -295,3 +295,86 @@ export function seekToVerse(verseClicked) { //forces highlighting change updateTime(); } + +// This algorithm is based on the Android code in +// AudioVideoFileManager::getExtractAudioProcessingTask +export function getTimingForVerseRange(verseRange: string) { + const timing = currentAudioPlayer.timing; + + if (timing && timing.length > 0) { + // Ensure the final timing has its end time set properly + const lastTiming = timing[timing.length - 1]; + if (lastTiming.endtime - lastTiming.starttime < 0.01) { + lastTiming.endtime = currentAudioPlayer.audio.duration; + } + } + + let timing1 = null; + let timing2 = null; + + const vs = verseRange.split('-'); + if (vs.length === 1) { + const verse = vs[0]; + timing1 = timing.find((t) => t.tag.includes(verse)); + timing2 = timing1; + } + const startVerse = verseRange.split('-')[0]; + const endVerse = verseRange.split('-')[1]; + const startTiming = timing.find((t) => t.tag.includes(startVerse)); + const endTiming = timing.find((t) => t.tag.includes(endVerse)); + return { + start: startTiming, + end: endTiming + }; +} + +function getTimingForVerse(timings: Array, verse: string) { + const result = []; + for (let i = 0; i < timings.length; i++) { + if (!isTimingForVerseRange(timings[i])) { + // This timing is for a single verse or phrases in a verse + // e.g. "4" - return timings 4a, 4b, 4c. + if (getVerseForTiming(timings[i]) === verse) { + result.push(timings[i]); + } + } else { + } + } +} + +function getVerseForTiming(timing: any) { + function extractNumericalPart(inputString) { + let numericalPart = ''; + let index = 0; + + // Skip non-numerical characters at the beginning of the string + while (index < inputString.length && isNaN(parseInt(inputString[index]))) { + index++; + } + + // Extract numerical characters until a non-numerical character is encountered + while (index < inputString.length && !isNaN(parseInt(inputString[index]))) { + numericalPart += inputString[index]; + index++; + } + + // Return the numerical part or null if no numerical part is found + return numericalPart.length > 0 ? numericalPart : null; + } + + return extractNumericalPart(timing.tag); +} + +function isVerseNumberInVerseRange(verse: string, verseRange: string) { + const vs = verseRange.split('-'); + if (vs.length === 1) { + return verse === vs[0]; + } + const startVerse = vs[0]; + const endVerse = vs[1]; + return verse >= startVerse && verse <= endVerse; +} + +function isTimingForVerseRange(timing: any) { + return timing.tag && timing.tag.includes('-'); +}