|
<script lang="ts"> |
|
import { Play, Pause, Forward, Backward, Undo, Trim } from "@gradio/icons"; |
|
import { get_skip_rewind_amount } from "../shared/utils"; |
|
import type { I18nFormatter } from "@gradio/utils"; |
|
import WaveSurfer from "wavesurfer.js"; |
|
import RegionsPlugin, { |
|
type Region |
|
} from "wavesurfer.js/dist/plugins/regions.js"; |
|
import type { WaveformOptions } from "./types"; |
|
import VolumeLevels from "./VolumeLevels.svelte"; |
|
import VolumeControl from "./VolumeControl.svelte"; |
|
|
|
export let waveform: WaveSurfer | undefined; |
|
export let audio_duration: number; |
|
export let i18n: I18nFormatter; |
|
export let playing: boolean; |
|
export let show_redo = false; |
|
export let interactive = false; |
|
export let handle_trim_audio: (start: number, end: number) => void; |
|
export let mode = ""; |
|
export let container: HTMLDivElement; |
|
export let handle_reset_value: () => void; |
|
export let waveform_options: WaveformOptions = {}; |
|
export let trim_region_settings: WaveformOptions = {}; |
|
export let show_volume_slider = false; |
|
export let editable = true; |
|
|
|
export let trimDuration = 0; |
|
|
|
let playbackSpeeds = [0.5, 1, 1.5, 2]; |
|
let playbackSpeed = playbackSpeeds[1]; |
|
|
|
let trimRegion: RegionsPlugin | null = null; |
|
let activeRegion: Region | null = null; |
|
|
|
let leftRegionHandle: HTMLDivElement | null; |
|
let rightRegionHandle: HTMLDivElement | null; |
|
let activeHandle = ""; |
|
|
|
let currentVolume = 1; |
|
|
|
$: trimRegion = |
|
container && waveform |
|
? waveform.registerPlugin(RegionsPlugin.create()) |
|
: null; |
|
|
|
$: trimRegion?.on("region-out", (region) => { |
|
region.play(); |
|
}); |
|
|
|
$: trimRegion?.on("region-updated", (region) => { |
|
trimDuration = region.end - region.start; |
|
}); |
|
|
|
$: trimRegion?.on("region-clicked", (region, e) => { |
|
e.stopPropagation(); // prevent triggering a click on the waveform |
|
activeRegion = region; |
|
region.play(); |
|
}); |
|
|
|
const addTrimRegion = (): void => { |
|
if (!trimRegion) return; |
|
activeRegion = trimRegion?.addRegion({ |
|
start: audio_duration / 4, |
|
end: audio_duration / 2, |
|
...trim_region_settings |
|
}); |
|
|
|
trimDuration = activeRegion.end - activeRegion.start; |
|
}; |
|
|
|
$: if (activeRegion) { |
|
const shadowRoot = container.children[0]!.shadowRoot!; |
|
|
|
rightRegionHandle = shadowRoot.querySelector('[data-resize="right"]'); |
|
leftRegionHandle = shadowRoot.querySelector('[data-resize="left"]'); |
|
|
|
if (leftRegionHandle && rightRegionHandle) { |
|
leftRegionHandle.setAttribute("role", "button"); |
|
rightRegionHandle.setAttribute("role", "button"); |
|
leftRegionHandle?.setAttribute("aria-label", "Drag to adjust start time"); |
|
rightRegionHandle?.setAttribute("aria-label", "Drag to adjust end time"); |
|
leftRegionHandle?.setAttribute("tabindex", "0"); |
|
rightRegionHandle?.setAttribute("tabindex", "0"); |
|
|
|
leftRegionHandle.addEventListener("focus", () => { |
|
if (trimRegion) activeHandle = "left"; |
|
}); |
|
|
|
rightRegionHandle.addEventListener("focus", () => { |
|
if (trimRegion) activeHandle = "right"; |
|
}); |
|
} |
|
} |
|
|
|
const trimAudio = (): void => { |
|
if (waveform && trimRegion) { |
|
if (activeRegion) { |
|
const start = activeRegion.start; |
|
const end = activeRegion.end; |
|
handle_trim_audio(start, end); |
|
mode = ""; |
|
activeRegion = null; |
|
} |
|
} |
|
}; |
|
|
|
const clearRegions = (): void => { |
|
trimRegion?.getRegions().forEach((region) => { |
|
region.remove(); |
|
}); |
|
trimRegion?.clearRegions(); |
|
}; |
|
|
|
const toggleTrimmingMode = (): void => { |
|
clearRegions(); |
|
if (mode === "edit") { |
|
mode = ""; |
|
} else { |
|
mode = "edit"; |
|
addTrimRegion(); |
|
} |
|
}; |
|
|
|
const adjustRegionHandles = (handle: string, key: string): void => { |
|
let newStart; |
|
let newEnd; |
|
|
|
if (!activeRegion) return; |
|
if (handle === "left") { |
|
if (key === "ArrowLeft") { |
|
newStart = activeRegion.start - 0.05; |
|
newEnd = activeRegion.end; |
|
} else { |
|
newStart = activeRegion.start + 0.05; |
|
newEnd = activeRegion.end; |
|
} |
|
} else { |
|
if (key === "ArrowLeft") { |
|
newStart = activeRegion.start; |
|
newEnd = activeRegion.end - 0.05; |
|
} else { |
|
newStart = activeRegion.start; |
|
newEnd = activeRegion.end + 0.05; |
|
} |
|
} |
|
|
|
activeRegion.setOptions({ |
|
start: newStart, |
|
end: newEnd |
|
}); |
|
|
|
trimDuration = activeRegion.end - activeRegion.start; |
|
}; |
|
|
|
$: trimRegion && |
|
window.addEventListener("keydown", (e) => { |
|
if (e.key === "ArrowLeft") { |
|
adjustRegionHandles(activeHandle, "ArrowLeft"); |
|
} else if (e.key === "ArrowRight") { |
|
adjustRegionHandles(activeHandle, "ArrowRight"); |
|
} |
|
}); |
|
</script> |
|
|
|
<div class="controls" data-testid="waveform-controls"> |
|
<div class="control-wrapper"> |
|
<button |
|
class="action icon volume" |
|
style:color={show_volume_slider |
|
? "var(--color-accent)" |
|
: "var(--neutral-400)"} |
|
aria-label="Adjust volume" |
|
on:click={() => (show_volume_slider = !show_volume_slider)} |
|
> |
|
<VolumeLevels {currentVolume} /> |
|
</button> |
|
|
|
{#if show_volume_slider} |
|
<VolumeControl bind:currentVolume bind:show_volume_slider {waveform} /> |
|
{/if} |
|
|
|
<button |
|
class:hidden={show_volume_slider} |
|
class="playback icon" |
|
aria-label={`Adjust playback speed to ${ |
|
playbackSpeeds[ |
|
(playbackSpeeds.indexOf(playbackSpeed) + 1) % playbackSpeeds.length |
|
] |
|
}x`} |
|
on:click={() => { |
|
playbackSpeed = |
|
playbackSpeeds[ |
|
(playbackSpeeds.indexOf(playbackSpeed) + 1) % playbackSpeeds.length |
|
]; |
|
|
|
waveform?.setPlaybackRate(playbackSpeed); |
|
}} |
|
> |
|
<span>{playbackSpeed}x</span> |
|
</button> |
|
</div> |
|
|
|
<div class="play-pause-wrapper"> |
|
<button |
|
class="rewind icon" |
|
aria-label={`Skip backwards by ${get_skip_rewind_amount( |
|
audio_duration, |
|
waveform_options.skip_length |
|
)} seconds`} |
|
on:click={() => |
|
waveform?.skip( |
|
get_skip_rewind_amount(audio_duration, waveform_options.skip_length) * |
|
-1 |
|
)} |
|
> |
|
<Backward /> |
|
</button> |
|
<button |
|
class="play-pause-button icon" |
|
on:click={() => waveform?.playPause()} |
|
aria-label={playing ? i18n("audio.pause") : i18n("audio.play")} |
|
> |
|
{#if playing} |
|
<Pause /> |
|
{:else} |
|
<Play /> |
|
{/if} |
|
</button> |
|
<button |
|
class="skip icon" |
|
aria-label="Skip forward by {get_skip_rewind_amount( |
|
audio_duration, |
|
waveform_options.skip_length |
|
)} seconds" |
|
on:click={() => |
|
waveform?.skip( |
|
get_skip_rewind_amount(audio_duration, waveform_options.skip_length) |
|
)} |
|
> |
|
<Forward /> |
|
</button> |
|
</div> |
|
|
|
<div class="settings-wrapper"> |
|
{#if editable && interactive} |
|
{#if show_redo && mode === ""} |
|
<button |
|
class="action icon" |
|
aria-label="Reset audio" |
|
on:click={() => { |
|
handle_reset_value(); |
|
clearRegions(); |
|
mode = ""; |
|
}} |
|
> |
|
<Undo /> |
|
</button> |
|
{/if} |
|
|
|
{#if mode === ""} |
|
<button |
|
class="action icon" |
|
aria-label="Trim audio to selection" |
|
on:click={toggleTrimmingMode} |
|
> |
|
<Trim /> |
|
</button> |
|
{:else} |
|
<button class="text-button" on:click={trimAudio}>Trim</button> |
|
<button class="text-button" on:click={toggleTrimmingMode}>Cancel</button |
|
> |
|
{/if} |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<style> |
|
.settings-wrapper { |
|
display: flex; |
|
justify-self: self-end; |
|
align-items: center; |
|
grid-area: editing; |
|
} |
|
.text-button { |
|
border: 1px solid var(--neutral-400); |
|
border-radius: var(--radius-sm); |
|
font-weight: 300; |
|
font-size: var(--size-3); |
|
text-align: center; |
|
color: var(--neutral-400); |
|
height: var(--size-5); |
|
font-weight: bold; |
|
padding: 0 5px; |
|
margin-left: 5px; |
|
} |
|
|
|
.text-button:hover, |
|
.text-button:focus { |
|
color: var(--color-accent); |
|
border-color: var(--color-accent); |
|
} |
|
|
|
.controls { |
|
display: grid; |
|
grid-template-columns: 1fr 1fr 1fr; |
|
grid-template-areas: "controls playback editing"; |
|
margin-top: 5px; |
|
align-items: center; |
|
position: relative; |
|
flex-wrap: wrap; |
|
justify-content: space-between; |
|
} |
|
.controls div { |
|
margin: var(--size-1) 0; |
|
} |
|
|
|
@media (max-width: 600px) { |
|
.controls { |
|
grid-template-columns: 1fr 1fr; |
|
grid-template-rows: auto auto; |
|
grid-template-areas: |
|
"playback playback" |
|
"controls editing"; |
|
} |
|
} |
|
|
|
@media (max-width: 319px) { |
|
.controls { |
|
overflow-x: scroll; |
|
} |
|
} |
|
|
|
.hidden { |
|
display: none; |
|
} |
|
|
|
.control-wrapper { |
|
display: flex; |
|
justify-self: self-start; |
|
align-items: center; |
|
justify-content: space-between; |
|
grid-area: controls; |
|
} |
|
|
|
.action { |
|
width: var(--size-5); |
|
color: var(--neutral-400); |
|
margin-left: var(--spacing-md); |
|
} |
|
.icon:hover, |
|
.icon:focus { |
|
color: var(--color-accent); |
|
} |
|
.play-pause-wrapper { |
|
display: flex; |
|
justify-self: center; |
|
grid-area: playback; |
|
} |
|
|
|
@media (max-width: 600px) { |
|
.play-pause-wrapper { |
|
margin: var(--spacing-md); |
|
} |
|
} |
|
.playback { |
|
border: 1px solid var(--neutral-400); |
|
border-radius: var(--radius-sm); |
|
width: 5.5ch; |
|
font-weight: 300; |
|
font-size: var(--size-3); |
|
text-align: center; |
|
color: var(--neutral-400); |
|
height: var(--size-5); |
|
font-weight: bold; |
|
} |
|
|
|
.playback:hover, |
|
.playback:focus { |
|
color: var(--color-accent); |
|
border-color: var(--color-accent); |
|
} |
|
|
|
.rewind, |
|
.skip { |
|
margin: 0 10px; |
|
color: var(--neutral-400); |
|
} |
|
|
|
.play-pause-button { |
|
width: var(--size-8); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
color: var(--neutral-400); |
|
fill: var(--neutral-400); |
|
} |
|
|
|
.volume { |
|
position: relative; |
|
display: flex; |
|
justify-content: center; |
|
margin-right: var(--spacing-xl); |
|
width: var(--size-5); |
|
} |
|
</style> |
|
|