-
-
Notifications
You must be signed in to change notification settings - Fork 39
Implement Audio Sharing for Selected Text #992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3443678
2356611
23c6ab7
8d21fb4
0c65991
18a0141
80f3393
959bbfe
f6c9bbd
6a02725
dfdd411
a9ba6f3
c30786e
b88ec6c
1c5a5e1
d55d502
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| <!-- | ||
| @component | ||
| A component providing a dropdown where you can choose to download audio or video for selected text | ||
| --> | ||
|
|
||
| <script lang="ts"> | ||
| import { scriptureConfig } from '$assets/config'; | ||
| import { getBook, logShareContent } from '$lib/data/analytics'; | ||
| import { getAudioSourceInfo } from '$lib/data/audio'; | ||
| import { shareAudio, shareText } from '$lib/data/share'; | ||
| import { | ||
| convertStyle, | ||
| getPositioningCSS, | ||
| modal, | ||
| ModalType, | ||
| refs, | ||
| s, | ||
| selectedVerses, | ||
| t | ||
| } from '$lib/data/stores'; | ||
| import { AudioIcon } from '$lib/icons'; | ||
| import FormatAlignLeftIcon from '$lib/icons/image/FormatAlignLeftIcon.svelte'; | ||
| import { | ||
| AudioBufferSource, | ||
| BufferTarget, | ||
| canEncodeAudio, | ||
| Mp4OutputFormat, | ||
| Output, | ||
| WavOutputFormat, | ||
| WebMOutputFormat | ||
| } from 'mediabunny'; | ||
| import type { AudioEncodingConfig } from 'mediabunny'; | ||
| import Modal from './Modal.svelte'; | ||
|
|
||
| let { vertOffset = '2rem' } = $props(); | ||
| async function shareSelectedText() { | ||
| const book = $selectedVerses[0].book; | ||
| const reference = selectedVerses.getCompositeReference(); | ||
| const text = await selectedVerses.getCompositeText(); | ||
| const bookCol = $selectedVerses[0].collection; | ||
| const fullBook = getBook({ collection: bookCol, book: book }); | ||
| const bookAbbrev = fullBook?.abbreviation ?? fullBook?.name; | ||
| const copyShareMessage = scriptureConfig.bookCollections?.find( | ||
| (x) => x.id === bookCol | ||
| )?.copyShareMessage; | ||
| shareText( | ||
| scriptureConfig.name ?? '', | ||
| text + '\n' + reference + (copyShareMessage ? '\n' + copyShareMessage : ''), | ||
| book + '.txt' | ||
| ); | ||
| logShareContent('Text', bookCol, bookAbbrev ?? '', reference); | ||
| } | ||
| async function shareAudioFile() { | ||
| const reference = selectedVerses.getCompositeReference(); | ||
| const audioCtx = new AudioContext(); | ||
| try { | ||
| const audioConfig: AudioEncodingConfig = await pickSupportedAudioConfig(); | ||
| const outputFormat = | ||
| audioConfig.codec === 'aac' | ||
| ? new Mp4OutputFormat() | ||
| : audioConfig.codec === 'opus' | ||
| ? new WebMOutputFormat() | ||
| : new WavOutputFormat(); | ||
| const output = new Output({ | ||
| format: outputFormat, | ||
| target: new BufferTarget() | ||
| }); | ||
|
|
||
| const audioSourceInfo = await getAudioSourceInfo({ | ||
| collection: $refs.collection, | ||
| book: $refs.book, | ||
| chapter: $refs.chapter | ||
| }); | ||
| if (!audioSourceInfo?.source) { | ||
| throw new Error('No audio source available for this chapter'); | ||
| } | ||
|
|
||
| const audioSource = new AudioBufferSource(audioConfig); | ||
| output.addAudioTrack(audioSource); | ||
| await output.start(); | ||
|
|
||
| const audioBlob = await fetch(audioSourceInfo?.source).then((r) => r.blob()); | ||
| const audioBuffer = await audioCtx.decodeAudioData(await audioBlob.arrayBuffer()); | ||
|
|
||
| const sampleRate = audioBuffer.sampleRate; | ||
|
|
||
| for (let i = 0; i < $selectedVerses.length; i++) { | ||
| let startFrame = 0; | ||
| let endFrame = 0; | ||
| let foundVerseTiming = false; | ||
| for (var j = 0; j < (audioSourceInfo?.timing?.length || 0); j++) { | ||
| const timing = audioSourceInfo?.timing?.[j]; | ||
| const verse = timing?.tag?.replace(/\D/g, ''); | ||
| if (verse === $selectedVerses[i].verse) { | ||
| if (!foundVerseTiming) { | ||
| foundVerseTiming = true; | ||
| startFrame = Math.floor((timing?.starttime || 0) * sampleRate); | ||
| endFrame = Math.floor((timing?.endtime || 0) * sampleRate); | ||
| } else { | ||
| endFrame = Math.floor((timing?.endtime || 0) * sampleRate); | ||
| } | ||
| } | ||
| } | ||
| if (!foundVerseTiming || endFrame <= startFrame) { | ||
| console.warn(`No timing found for verse ${$selectedVerses[i].verse}, skipping`); | ||
| continue; | ||
| } | ||
| const trimmedBuffer = audioCtx.createBuffer( | ||
| audioBuffer.numberOfChannels, | ||
| endFrame - startFrame, | ||
| sampleRate | ||
| ); | ||
|
|
||
| for (let ch = 0; ch < audioBuffer.numberOfChannels; ch++) { | ||
| const src = audioBuffer.getChannelData(ch); | ||
| const dst = trimmedBuffer.getChannelData(ch); | ||
| dst.set(src.slice(startFrame, endFrame)); | ||
| } | ||
|
|
||
| await audioSource.add(trimmedBuffer); | ||
| } | ||
|
TheNonPirate marked this conversation as resolved.
|
||
| await output.finalize(); | ||
|
|
||
| await shareAudio( | ||
| reference, | ||
| await selectedVerses.getCompositeText(), | ||
| reference.replace(/[\\/:*?"<>|]/g, '_') + outputFormat.fileExtension, | ||
| new Blob([output.target.buffer as BlobPart], { | ||
| type: outputFormat.mimeType | ||
| }), | ||
| outputFormat.mimeType | ||
| ); | ||
|
TheNonPirate marked this conversation as resolved.
|
||
| } catch (error) { | ||
| modal.open(ModalType.AudioAlert, 'Audio_Download_Error'); | ||
| console.error('Error generating audio export:', error); | ||
| } finally { | ||
| await audioCtx?.close(); | ||
| } | ||
|
FyreByrd marked this conversation as resolved.
|
||
| } | ||
| async function pickSupportedAudioConfig() { | ||
| const candidates: AudioEncodingConfig[] = [ | ||
| { codec: 'aac', bitrate: 128000 }, | ||
| { codec: 'aac', bitrate: 96000 }, | ||
| { codec: 'aac', bitrate: 64000 }, | ||
| { | ||
| codec: 'opus', | ||
| bitrate: 96000 | ||
| }, | ||
| { codec: 'pcm-f32' }, | ||
| { codec: 'pcm-s24' }, | ||
| { codec: 'pcm-s16' } | ||
| ]; | ||
|
|
||
| for (const cfg of candidates) { | ||
| if (await canEncodeAudio(cfg.codec, cfg)) { | ||
| return cfg; | ||
| } | ||
| } | ||
|
|
||
| throw new Error('No supported audio configuration found.'); | ||
| } //This is used to determine a supported audio configuration. It first tries AAC, but then falls back to opus if AAC isn't supported. This is a duplicate of the function with the same name in VerseOnImage.svelte, so maybe it should be moved to somewhere that exports it for any place that needs it to use it? | ||
| let modalId = 'shareSelector'; | ||
| let modalThis: Modal; | ||
| export function showModal(shareTextOnly: boolean) { | ||
| if (shareTextOnly) { | ||
| shareSelectedText(); | ||
| } else { | ||
| modalThis.showModal(); | ||
| } | ||
| } | ||
| const positioningCSS = $derived(getPositioningCSS(vertOffset, 'bottom')); | ||
| const iconColor = $derived($s?.['ui.bar.audio.hint.text']?.['color'] || 'black'); | ||
| </script> | ||
|
|
||
| <!-- svelte-ignore a11y_consider_explicit_label --> | ||
| <Modal | ||
| bind:this={modalThis} | ||
| id={modalId} | ||
| styling="box-shadow:none; padding:0; width:auto; {positioningCSS}" | ||
| > | ||
|
TheNonPirate marked this conversation as resolved.
|
||
| <div class="grid message" id="container"> | ||
| <button | ||
| class="dy-btn flex items-center justify-center rounded-none border-transparent" | ||
| style={convertStyle($s?.['ui.bar.audio.hint.text'])} | ||
| onclick={() => shareSelectedText()} | ||
| > | ||
| <FormatAlignLeftIcon color={iconColor} /> | ||
| {$t['Share_Text']} | ||
| </button> | ||
| <button | ||
| class="dy-btn flex items-center justify-center rounded-none border-transparent" | ||
| style={convertStyle($s?.['ui.bar.audio.hint.text'])} | ||
| onclick={() => shareAudioFile()} | ||
| > | ||
| <AudioIcon.Volume color={iconColor} /> | ||
| {$t['Share_Audio']} | ||
| </button> | ||
| <!--<button | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this commented out?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's commented out because video download has not yet been implemented, but that is where the button would go if it was implemented. |
||
| class="dy-btn flex items-center justify-center rounded-none" | ||
| onclick={() => downloadVideo()} | ||
| > | ||
| <VideoIcon /> | ||
| {$t['Share_Video']} | ||
| </button>--> | ||
| </div> | ||
| </Modal> | ||
Uh oh!
There was an error while loading. Please reload this page.