adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
380 lines
8.1 KiB
Svelte
380 lines
8.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, tick } from "svelte";
|
|
import WaveSurfer from "wavesurfer.js";
|
|
import RecordPlugin from "wavesurfer.js/dist/plugins/record.js";
|
|
import { format_time } from "@gradio/utils";
|
|
import { process_audio } from "./utils";
|
|
import { prepare_files, type FileData, type Client } from "@gradio/client";
|
|
import { Square } from "@gradio/icons";
|
|
|
|
let {
|
|
label,
|
|
waveform_settings = {},
|
|
recording = $bindable(),
|
|
upload,
|
|
root,
|
|
max_file_size = null,
|
|
upload_promise = $bindable(),
|
|
onchange,
|
|
onstoprecording,
|
|
onclear
|
|
}: {
|
|
label: string;
|
|
waveform_settings?: Record<string, any>;
|
|
recording?: boolean;
|
|
upload: Client["upload"];
|
|
root: string;
|
|
max_file_size?: number | null;
|
|
upload_promise?: Promise<any> | null;
|
|
onchange?: (value: FileData) => void;
|
|
onstoprecording?: () => void;
|
|
onclear?: () => void;
|
|
} = $props();
|
|
|
|
let container: HTMLDivElement;
|
|
let waveform: WaveSurfer | undefined;
|
|
let record: RecordPlugin | undefined = $state();
|
|
let seconds = $state(0);
|
|
let interval: NodeJS.Timeout;
|
|
let is_recording = $state(false);
|
|
let has_started = $state(false);
|
|
let mic_devices: MediaDeviceInfo[] = $state([]);
|
|
let selected_device_id: string = $state("");
|
|
let show_device_selection = $state(false);
|
|
|
|
const start_interval = (): void => {
|
|
clearInterval(interval);
|
|
interval = setInterval(() => {
|
|
seconds++;
|
|
}, 1000);
|
|
};
|
|
|
|
const create_waveform = async (): Promise<void> => {
|
|
if (!container) return;
|
|
|
|
if (waveform) {
|
|
waveform.destroy();
|
|
}
|
|
|
|
const accentColor =
|
|
getComputedStyle(document.documentElement).getPropertyValue(
|
|
"--color-accent"
|
|
) || "#ff7c00";
|
|
|
|
waveform = WaveSurfer.create({
|
|
container,
|
|
height: 32,
|
|
waveColor: "rgba(128, 128, 128, 0.5)",
|
|
progressColor: accentColor,
|
|
cursorColor: "transparent",
|
|
barWidth: 2,
|
|
barGap: 2,
|
|
barRadius: 2,
|
|
interact: false,
|
|
hideScrollbar: true,
|
|
...waveform_settings
|
|
});
|
|
|
|
record = waveform.registerPlugin(
|
|
RecordPlugin.create({
|
|
scrollingWaveform: true,
|
|
scrollingWaveformWindow: 7,
|
|
renderRecordedAudio: false
|
|
})
|
|
);
|
|
|
|
record.on("record-start", () => {
|
|
start_interval();
|
|
is_recording = true;
|
|
has_started = true;
|
|
});
|
|
|
|
record.on("record-end", async (blob: Blob) => {
|
|
clearInterval(interval);
|
|
is_recording = false;
|
|
|
|
upload_promise = (async () => {
|
|
try {
|
|
const array_buffer = await blob.arrayBuffer();
|
|
const context = new AudioContext({
|
|
sampleRate: waveform_settings.sampleRate || 44100
|
|
});
|
|
const audio_buffer = await context.decodeAudioData(array_buffer);
|
|
|
|
if (audio_buffer) {
|
|
const audio = await process_audio(audio_buffer);
|
|
const audio_blob = new File([new Uint8Array(audio)], "audio.wav", {
|
|
type: "audio/wav"
|
|
});
|
|
|
|
const prepared_files = await prepare_files([audio_blob], false);
|
|
const uploaded_files = await upload(
|
|
prepared_files,
|
|
root,
|
|
undefined,
|
|
max_file_size || undefined
|
|
);
|
|
const file_data = uploaded_files?.[0];
|
|
|
|
if (file_data) {
|
|
onchange?.(file_data);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Error processing audio:", e);
|
|
} finally {
|
|
onstoprecording?.();
|
|
upload_promise = null;
|
|
}
|
|
})();
|
|
|
|
await upload_promise;
|
|
});
|
|
};
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const devices = await RecordPlugin.getAvailableAudioDevices();
|
|
mic_devices = devices.filter((device) => device.deviceId);
|
|
if (mic_devices.length > 0) {
|
|
selected_device_id = mic_devices[0].deviceId;
|
|
}
|
|
|
|
if (mic_devices.length > 1) {
|
|
show_device_selection = true;
|
|
} else {
|
|
await create_waveform();
|
|
}
|
|
} catch (err) {
|
|
await create_waveform();
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
clearInterval(interval);
|
|
if (record) {
|
|
record.stopMic();
|
|
}
|
|
if (waveform) {
|
|
waveform.destroy();
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (
|
|
recording &&
|
|
!is_recording &&
|
|
record &&
|
|
has_started === false &&
|
|
mic_devices.length <= 1
|
|
) {
|
|
record
|
|
.startMic({ deviceId: selected_device_id })
|
|
.then(() => {
|
|
record?.startRecording();
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to access microphone:", err);
|
|
onclear?.();
|
|
});
|
|
} else if (!recording && is_recording && record) {
|
|
record.stopRecording();
|
|
seconds = 0;
|
|
}
|
|
});
|
|
|
|
async function startRecording(): Promise<void> {
|
|
show_device_selection = false;
|
|
has_started = true;
|
|
|
|
await tick();
|
|
await create_waveform();
|
|
|
|
if (!record) return;
|
|
|
|
try {
|
|
await record.startMic({ deviceId: selected_device_id });
|
|
record.startRecording();
|
|
} catch (err) {
|
|
console.error("Error starting recording:", err);
|
|
show_device_selection = mic_devices.length > 1;
|
|
has_started = false;
|
|
recording = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="minimal-audio-recorder"
|
|
aria-label={label || "Audio Recorder"}
|
|
data-testid="minimal-audio-recorder"
|
|
>
|
|
{#if show_device_selection}
|
|
<div class="device-selection-wrapper">
|
|
{#if mic_devices.length > 1}
|
|
<select
|
|
bind:value={selected_device_id}
|
|
class="device-select-large"
|
|
aria-label="Select input device"
|
|
>
|
|
{#each mic_devices as device}
|
|
<option value={device.deviceId}>{device.label}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
<button
|
|
class="record-button"
|
|
onclick={startRecording}
|
|
aria-label="Start recording"
|
|
>
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="waveform-wrapper" bind:this={container}></div>
|
|
<div class="timestamp">{format_time(seconds)}</div>
|
|
<button
|
|
class="stop-button"
|
|
onclick={() => {
|
|
if (is_recording) {
|
|
recording = false;
|
|
} else {
|
|
onclear?.();
|
|
}
|
|
}}
|
|
aria-label="Stop recording"
|
|
>
|
|
<Square />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.minimal-audio-recorder {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
border-radius: var(--radius-sm);
|
|
width: 100%;
|
|
padding: var(--spacing-sm);
|
|
}
|
|
|
|
.device-selection-wrapper {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--spacing-md);
|
|
width: 100%;
|
|
}
|
|
|
|
.waveform-wrapper {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.waveform-wrapper :global(::part(wrapper)) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.timestamp {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--body-text-color);
|
|
opacity: 0.7;
|
|
font-variant-numeric: tabular-nums;
|
|
flex-shrink: 0;
|
|
min-width: 40px;
|
|
text-align: center;
|
|
}
|
|
|
|
.device-select-large {
|
|
max-width: var(--size-60);
|
|
font-size: var(--text-sm);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border: 1px solid var(--border-color-primary);
|
|
border-radius: var(--radius-md);
|
|
background: var(--background-fill-secondary);
|
|
color: var(--body-text-color);
|
|
cursor: pointer;
|
|
height: var(--size-9);
|
|
}
|
|
|
|
.record-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: var(--size-9);
|
|
width: var(--size-9);
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
background-color: var(--block-background-fill);
|
|
color: var(--body-text-color);
|
|
border: 1px solid var(--border-color-primary);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.record-button::before {
|
|
content: "";
|
|
height: var(--size-4);
|
|
width: var(--size-4);
|
|
border-radius: var(--radius-full);
|
|
background: var(--primary-600);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.record-button:hover {
|
|
background-color: var(--block-background-fill);
|
|
border-color: var(--border-color-accent);
|
|
}
|
|
|
|
.record-button:active {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.stop-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: var(--size-9);
|
|
height: var(--size-9);
|
|
padding: 0;
|
|
border: var(--size-px) solid var(--border-color-primary);
|
|
border-radius: var(--radius-md);
|
|
background: var(--button-secondary-background-fill);
|
|
color: var(--error-500);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stop-button:hover {
|
|
background: var(--button-secondary-background-fill-hover);
|
|
color: var(--error-600);
|
|
}
|
|
|
|
.stop-button:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.stop-button :global(svg) {
|
|
width: var(--size-5);
|
|
height: var(--size-5);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.record-btn {
|
|
transition: none;
|
|
}
|
|
.record-btn.recording {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.device-select-large {
|
|
max-width: var(--size-40);
|
|
}
|
|
}
|
|
</style>
|