You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/29 22:09:06 UTC
(texera) branch gh-readonly-queue/main/pr-5567-d10e1a29b8704ed9453134cb87e20828ed7684c4 created (now ecfafe0bb2)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5567-d10e1a29b8704ed9453134cb87e20828ed7684c4
in repository https://gitbox.apache.org/repos/asf/texera.git
at ecfafe0bb2 feat(frontend): add HuggingFace audio upload component (#5567)
This branch includes the following new commits:
new ecfafe0bb2 feat(frontend): add HuggingFace audio upload component (#5567)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat(frontend): add HuggingFace audio upload component (#5567)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5567-d10e1a29b8704ed9453134cb87e20828ed7684c4
in repository https://gitbox.apache.org/repos/asf/texera.git
commit ecfafe0bb28478f3a844676758eb59de252ed9cc
Author: Elliot Lin <36...@users.noreply.github.com>
AuthorDate: Mon Jun 29 15:08:46 2026 -0700
feat(frontend): add HuggingFace audio upload component (#5567)
⚠️ This PR is stacked on #5566. Until that lands, the diff below may
also include PR 6a's task selector + model browser changes depending on
which base GitHub is showing. The new code in this PR is the
HuggingFaceAudioUploadComponent under
frontend/src/app/workspace/component/hugging-face-audio-upload/, plus
the formly registration in formly-config.ts and the declaration in
app.module.ts. Once PR 6a merges and this PR is retargeted to main, the
diff should auto-clean to the PR #5566 audio upload changes only.
### What changes were proposed in this PR?
Add `HuggingFaceAudioUploadComponent`, a custom formly field type
(`huggingface-audio-upload`) that provides:
- An audio file picker that uploads to the Texera backend's
`/huggingface/upload-audio` endpoint for server-side storage
- Local audio preview/playback using a browser Object URL while upload
is in progress
- Storage of the returned server path in the formly form control
- A guidance message explaining why audio is uploaded to backend storage
rather than embedded in the workflow JSON
This PR registers the component in `formly-config.ts` and declares it in
`AppModule`. The component is not yet wired into the HuggingFace
operator's property editor; the `jsonSchemaMapIntercept` mapping that
routes the `audioInput` field to this component is added in the
follow-up property-editor PR (PR 7).
### Any related issues, documentation, discussions?
- Tracking issue: #5314
- Closes: #5314
- Stacked on: #5566
- Parent issue: #5041
### How was this PR tested?
2 unit tests added in `hugging-face-audio-upload.component.spec.ts`
verifying the component class is defined and importable. Run with `ng
test`.
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.7
---------
Co-authored-by: Elliot <36...@users.noreply.github.com>
Co-authored-by: Anish Shivamurthy <an...@uci.edu>
Co-authored-by: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
frontend/src/app/app.module.ts | 2 +
frontend/src/app/common/formly/formly-config.ts | 2 +
.../hugging-face-audio-upload.component.html | 63 +++
.../hugging-face-audio-upload.component.scss | 68 +++
.../hugging-face-audio-upload.component.spec.ts | 484 +++++++++++++++++++++
.../hugging-face-audio-upload.component.ts | 192 ++++++++
6 files changed, 811 insertions(+)
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index bdebbfba2e..8f5023b50a 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -108,6 +108,7 @@ import { AgentChatComponent } from "./workspace/component/agent/agent-panel/agen
import { AgentRegistrationComponent } from "./workspace/component/agent/agent-panel/agent-registration/agent-registration.component";
import { HuggingFaceImageUploadComponent } from "./workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
import { HuggingFaceComponent } from "./workspace/component/hugging-face/hugging-face.component";
+import { HuggingFaceAudioUploadComponent } from "./workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component";
import { DatasetFileSelectorComponent } from "./workspace/component/dataset-file-selector/dataset-file-selector.component";
import { DatasetVersionSelectorComponent } from "./workspace/component/dataset-version-selector/dataset-version-selector.component";
import { DatasetSelectionModalComponent } from "./workspace/component/dataset-selection-modal/dataset-selection-modal.component";
@@ -333,6 +334,7 @@ registerLocaleData(en);
AgentRegistrationComponent,
AgentInteractionComponent,
HuggingFaceComponent,
+ HuggingFaceAudioUploadComponent,
HuggingFaceImageUploadComponent,
DatasetFileSelectorComponent,
DatasetVersionSelectorComponent,
diff --git a/frontend/src/app/common/formly/formly-config.ts b/frontend/src/app/common/formly/formly-config.ts
index f385cf0359..c4fc54fd77 100644
--- a/frontend/src/app/common/formly/formly-config.ts
+++ b/frontend/src/app/common/formly/formly-config.ts
@@ -31,6 +31,7 @@ import { UiUdfParametersComponent } from "../../workspace/component/ui-udf-param
import { DatasetVersionSelectorComponent } from "../../workspace/component/dataset-version-selector/dataset-version-selector.component";
import { HuggingFaceImageUploadComponent } from "../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
import { HuggingFaceComponent } from "../../workspace/component/hugging-face/hugging-face.component";
+import { HuggingFaceAudioUploadComponent } from "../../workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component";
/**
* Configuration for using Json Schema with Formly.
@@ -83,6 +84,7 @@ export const TEXERA_FORMLY_CONFIG = {
{ name: "inputautocomplete", component: DatasetFileSelectorComponent, wrappers: ["form-field"] },
{ name: "datasetversionselector", component: DatasetVersionSelectorComponent, wrappers: ["form-field"] },
{ name: "huggingface", component: HuggingFaceComponent, wrappers: ["form-field"] },
+ { name: "huggingface-audio-upload", component: HuggingFaceAudioUploadComponent, wrappers: ["form-field"] },
{ name: "huggingface-image-upload", component: HuggingFaceImageUploadComponent, wrappers: ["form-field"] },
{ name: "repeat-section-dnd", component: FormlyRepeatDndComponent },
{ name: "ui-udf-parameters", component: UiUdfParametersComponent, wrappers: ["form-field"] },
diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html
new file mode 100644
index 0000000000..507528e8d4
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html
@@ -0,0 +1,63 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+<div class="hf-audio-upload">
+ <div class="hf-audio-guidance">
+ Audio files are uploaded to temporary backend storage and referenced from the operator, so larger clips can be used
+ without bloating the workflow JSON.
+ </div>
+
+ <input
+ #fileInput
+ type="file"
+ accept="audio/*"
+ class="hf-audio-upload-input"
+ [disabled]="isUploading"
+ (change)="onFileSelected($event)" />
+
+ <div
+ *ngIf="previewSrc"
+ class="hf-audio-preview">
+ <audio
+ controls
+ [src]="previewSrc"></audio>
+ <div class="hf-audio-meta">
+ <span>{{ fileName || "Selected audio" }}</span>
+ <span
+ *ngIf="isUploading"
+ class="hf-audio-status"
+ >Uploading...</span
+ >
+ <button
+ nz-button
+ nzSize="small"
+ type="button"
+ [disabled]="isUploading"
+ (click)="clearAudio(fileInput)">
+ Clear
+ </button>
+ </div>
+ </div>
+
+ <div
+ *ngIf="errorMessage"
+ class="hf-audio-error">
+ {{ errorMessage }}
+ </div>
+</div>
diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss
new file mode 100644
index 0000000000..0757524e04
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss
@@ -0,0 +1,68 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.hf-audio-upload {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.hf-audio-guidance {
+ color: #595959;
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.hf-audio-upload-input {
+ width: 100%;
+}
+
+.hf-audio-preview {
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ padding: 8px;
+}
+
+.hf-audio-preview audio {
+ display: block;
+ width: 100%;
+}
+
+.hf-audio-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.hf-audio-meta span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hf-audio-status {
+ color: #595959;
+ font-size: 12px;
+}
+
+.hf-audio-error {
+ color: #cf1322;
+}
diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts
new file mode 100644
index 0000000000..bb7ebeb619
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts
@@ -0,0 +1,484 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { FormControl } from "@angular/forms";
+import { FieldTypeConfig } from "@ngx-formly/core";
+import { AppSettings } from "../../../common/app-setting";
+import { HuggingFaceAudioUploadComponent } from "./hugging-face-audio-upload.component";
+
+const API = "api";
+
+describe("HuggingFaceAudioUploadComponent", () => {
+ let component: HuggingFaceAudioUploadComponent;
+ let httpTestingController: HttpTestingController;
+ let formControl: FormControl;
+
+ function makeFileEvent(file: File | null): Event {
+ const input = document.createElement("input");
+ if (file) {
+ Object.defineProperty(input, "files", { value: [file] });
+ }
+ return { target: input } as unknown as Event;
+ }
+
+ function makeFileEventWithInput(file: File | null): { event: Event; input: HTMLInputElement } {
+ const input = document.createElement("input");
+ if (file) {
+ Object.defineProperty(input, "files", { value: [file] });
+ }
+ return { event: { target: input } as unknown as Event, input };
+ }
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HuggingFaceAudioUploadComponent, HttpClientTestingModule],
+ }).compileComponents();
+
+ vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+
+ const fixture = TestBed.createComponent(HuggingFaceAudioUploadComponent);
+ component = fixture.componentInstance;
+ formControl = new FormControl("");
+ component.field = { formControl, key: "audioInput", model: {} } as unknown as FieldTypeConfig;
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTestingController.verify();
+ });
+
+ it("should be defined", () => {
+ expect(component).toBeDefined();
+ });
+
+ // ── ngOnInit ──
+
+ describe("ngOnInit", () => {
+ it("should set fileName from existing formControl value", () => {
+ formControl.setValue("/uploads/my-clip.wav");
+ component.ngOnInit();
+ expect(component.fileName).toBe("my-clip.wav");
+ // ngOnInit fires an authenticated blob fetch for the server path
+ httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ });
+
+ it("should set fileName to 'Selected audio' for data:audio values", () => {
+ formControl.setValue("data:audio/wav;base64,abc123");
+ component.ngOnInit();
+ expect(component.fileName).toBe("Selected audio");
+ });
+
+ it("should not set fileName when formControl is empty", () => {
+ formControl.setValue("");
+ component.ngOnInit();
+ expect(component.fileName).toBe("");
+ });
+
+ it("should not set fileName when formControl is whitespace", () => {
+ formControl.setValue(" ");
+ component.ngOnInit();
+ expect(component.fileName).toBe("");
+ });
+ });
+
+ // ── previewSrc ──
+
+ describe("previewSrc", () => {
+ it("should return empty string when formControl is empty and no local preview", () => {
+ expect(component.previewSrc).toBe("");
+ });
+
+ it("should return empty for a stored server path (blob URL loaded asynchronously)", () => {
+ formControl.setValue("/uploads/clip.wav");
+ expect(component.previewSrc).toBe("");
+ });
+
+ it("should return data:audio value as-is", () => {
+ const dataUrl = "data:audio/wav;base64,abc123";
+ formControl.setValue(dataUrl);
+ expect(component.previewSrc).toBe(dataUrl);
+ });
+
+ it("should return empty string for whitespace-only value", () => {
+ formControl.setValue(" ");
+ expect(component.previewSrc).toBe("");
+ });
+ });
+
+ // ── File upload ──
+
+ describe("onFileSelected", () => {
+ it("should reject a non-audio file", async () => {
+ const file = new File(["data"], "doc.pdf", { type: "application/pdf" });
+ await component.onFileSelected(makeFileEvent(file));
+
+ expect(component.errorMessage).toBe("Choose an audio file.");
+ expect(formControl.value).toBe("");
+ });
+
+ it("should upload an audio file and set formControl value", async () => {
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ const req = httpTestingController.expectOne(
+ r => r.method === "POST" && r.url.includes("/huggingface/upload-audio")
+ );
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ expect(formControl.value).toBe("/tmp/clip.wav");
+ expect(component.fileName).toBe("clip.wav");
+ expect(component.isUploading).toBe(false);
+ });
+
+ it("should guard against concurrent uploads", async () => {
+ component.isUploading = true;
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ await component.onFileSelected(makeFileEvent(file));
+
+ httpTestingController.expectNone(r => r.url.includes("/huggingface/upload-audio"));
+ expect(formControl.value).toBe("");
+ });
+
+ it("should do nothing when no file is selected", async () => {
+ await component.onFileSelected(makeFileEvent(null));
+
+ httpTestingController.expectNone(r => r.url.includes("/huggingface/upload-audio"));
+ expect(formControl.value).toBe("");
+ expect(component.errorMessage).toBe("");
+ });
+
+ it("should set isUploading while upload is in progress", async () => {
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ expect(component.isUploading).toBe(true);
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ expect(component.isUploading).toBe(false);
+ });
+
+ it("should clear error message before new upload", async () => {
+ component.errorMessage = "previous error";
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ expect(component.errorMessage).toBe("");
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+ });
+
+ it("should show error on upload failure", async () => {
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.error(new ProgressEvent("error"));
+ await uploadPromise;
+
+ expect(component.errorMessage).toBe("Could not upload this audio file.");
+ expect(component.isUploading).toBe(false);
+ expect(formControl.value).toBe("");
+ });
+
+ it("should use file.name as fallback when response.fileName is empty", async () => {
+ const file = new File(["audio-data"], "my-clip.mp3", { type: "audio/mp3" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/my-clip.mp3", fileName: "" });
+ await uploadPromise;
+
+ expect(component.fileName).toBe("my-clip.mp3");
+ });
+
+ it("should update the model when key is a string", async () => {
+ const model: Record<string, unknown> = {};
+ component.field = { formControl, key: "audioInput", model } as unknown as FieldTypeConfig;
+
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ expect(model["audioInput"]).toBe("/tmp/clip.wav");
+ });
+
+ it("should send correct Content-Type and URL", async () => {
+ const file = new File(["audio-data"], "my clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ expect(req.request.url).toContain("filename=my%20clip.wav");
+ expect(req.request.headers.get("Content-Type")).toBe("application/octet-stream");
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+ });
+ });
+
+ // ── clearAudio ──
+
+ describe("clearAudio", () => {
+ it("should reset all state", () => {
+ component.fileName = "clip.wav";
+ component.errorMessage = "some error";
+ formControl.setValue("/tmp/clip.wav");
+
+ const input = document.createElement("input");
+ component.clearAudio(input);
+
+ expect(component.fileName).toBe("");
+ expect(component.errorMessage).toBe("");
+ expect(component.isUploading).toBe(false);
+ expect(formControl.value).toBe("");
+ });
+
+ it("should preserve error message when clearError is false", () => {
+ component.errorMessage = "upload failed";
+ const input = document.createElement("input");
+ component.clearAudio(input, false);
+
+ expect(component.errorMessage).toBe("upload failed");
+ });
+
+ it("should clear model value when key is a string", () => {
+ const model: Record<string, unknown> = { audioInput: "/tmp/clip.wav" };
+ component.field = { formControl, key: "audioInput", model } as unknown as FieldTypeConfig;
+
+ const input = document.createElement("input");
+ component.clearAudio(input);
+
+ expect(model["audioInput"]).toBe("");
+ });
+
+ it("should mark formControl as dirty and touched", () => {
+ const input = document.createElement("input");
+ component.clearAudio(input);
+
+ expect(formControl.dirty).toBe(true);
+ expect(formControl.touched).toBe(true);
+ });
+ });
+
+ // ── loadServerAudioPreview (via ngOnInit) ──
+
+ describe("loadServerAudioPreview", () => {
+ it("should set localPreviewUrl on successful blob fetch", async () => {
+ const blobUrl = "blob:http://localhost/fake-audio";
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
+
+ formControl.setValue("/uploads/clip.wav");
+ component.ngOnInit();
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ expect(req.request.responseType).toBe("blob");
+ req.flush(new Blob(["audio-data"], { type: "audio/wav" }));
+
+ // Allow microtask (promise .then) to settle
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(component.previewSrc).toBe(blobUrl);
+ });
+
+ it("should set errorMessage on blob fetch failure", async () => {
+ formControl.setValue("/uploads/clip.wav");
+ component.ngOnInit();
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ req.error(new ProgressEvent("error"));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(component.errorMessage).toBe("Could not load audio preview.");
+ });
+
+ it("should discard blob fetch result if formControl value changed", async () => {
+ const blobUrl = "blob:http://localhost/fake-audio";
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
+
+ formControl.setValue("/uploads/clip.wav");
+ component.ngOnInit();
+
+ // User cleared the field before fetch completes
+ formControl.setValue("");
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ req.flush(new Blob(["audio-data"], { type: "audio/wav" }));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(component.previewSrc).toBe("");
+ });
+
+ it("should discard error if formControl value changed before fetch fails", async () => {
+ formControl.setValue("/uploads/clip.wav");
+ component.ngOnInit();
+
+ formControl.setValue("");
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ req.error(new ProgressEvent("error"));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(component.errorMessage).toBe("");
+ });
+
+ it("should not fetch for data:audio values in ngOnInit", () => {
+ formControl.setValue("data:audio/wav;base64,abc123");
+ component.ngOnInit();
+
+ httpTestingController.expectNone(r => r.url.includes("/huggingface/audio-preview"));
+ });
+
+ it("should encode server path in the fetch URL", () => {
+ formControl.setValue("/uploads/my clip.wav");
+ component.ngOnInit();
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ expect(req.request.url).toContain("path=%2Fuploads%2Fmy%20clip.wav");
+ });
+ });
+
+ // ── previewSrc with localPreviewUrl ──
+
+ describe("previewSrc with localPreviewUrl", () => {
+ it("should return localPreviewUrl when set via file upload", async () => {
+ const blobUrl = "blob:http://localhost/local-preview";
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
+ vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ // After file selection, localPreviewUrl should be set
+ expect(component.previewSrc).toBe(blobUrl);
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+ });
+ });
+
+ // ── Stale upload guards ──
+
+ describe("stale upload guards", () => {
+ it("should discard successful upload if cleared during flight", async () => {
+ const blobUrl = "blob:http://localhost/local-preview";
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
+ vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+
+ const { event, input } = makeFileEventWithInput(new File(["audio-data"], "clip.wav", { type: "audio/wav" }));
+ const uploadPromise = component.onFileSelected(event);
+
+ // Clear while upload is in flight
+ component.clearAudio(input);
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ // Upload result should be discarded — formControl stays empty
+ expect(formControl.value).toBe("");
+ });
+
+ it("should discard upload error if cleared during flight", async () => {
+ const blobUrl = "blob:http://localhost/local-preview";
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
+ vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+
+ const { event, input } = makeFileEventWithInput(new File(["audio-data"], "clip.wav", { type: "audio/wav" }));
+ const uploadPromise = component.onFileSelected(event);
+
+ component.clearAudio(input);
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.error(new ProgressEvent("error"));
+ await uploadPromise;
+
+ // Error should be discarded — errorMessage stays empty (clearAudio clears it)
+ expect(component.errorMessage).toBe("");
+ });
+ });
+
+ // ── ngOnDestroy ──
+
+ describe("ngOnDestroy", () => {
+ it("should not throw on destroy", () => {
+ expect(() => component.ngOnDestroy()).not.toThrow();
+ });
+
+ it("should revoke localPreviewUrl on destroy", async () => {
+ const blobUrl = "blob:http://localhost/local-preview";
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
+ const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+
+ const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio"));
+ req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ component.ngOnDestroy();
+ expect(revokeSpy).toHaveBeenCalledWith(blobUrl);
+ });
+ });
+
+ // ── getDisplayName edge cases ──
+
+ describe("getDisplayName (via ngOnInit)", () => {
+ it("should extract filename from path with forward slashes", () => {
+ formControl.setValue("/path/to/my-clip.wav");
+ component.ngOnInit();
+ expect(component.fileName).toBe("my-clip.wav");
+ httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ });
+
+ it("should extract filename from path with backslashes", () => {
+ formControl.setValue("C:\\uploads\\my-clip.wav");
+ component.ngOnInit();
+ expect(component.fileName).toBe("my-clip.wav");
+ httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ });
+
+ it("should return 'Selected audio' for path ending with separator", () => {
+ formControl.setValue("/uploads/");
+ component.ngOnInit();
+ expect(component.fileName).toBe("Selected audio");
+ httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ });
+
+ it("should return filename for flat name without path", () => {
+ formControl.setValue("clip.wav");
+ component.ngOnInit();
+ expect(component.fileName).toBe("clip.wav");
+ httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview"));
+ });
+ });
+});
diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts
new file mode 100644
index 0000000000..fc2563a263
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts
@@ -0,0 +1,192 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FieldType, FieldTypeConfig } from "@ngx-formly/core";
+import { HttpClient } from "@angular/common/http";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { firstValueFrom } from "rxjs";
+import { AppSettings } from "../../../common/app-setting";
+
+interface HuggingFaceAudioUploadResponse {
+ path: string;
+ fileName: string;
+}
+
+@Component({
+ selector: "texera-hugging-face-audio-upload",
+ templateUrl: "./hugging-face-audio-upload.component.html",
+ styleUrls: ["./hugging-face-audio-upload.component.scss"],
+ imports: [CommonModule, NzButtonModule],
+})
+export class HuggingFaceAudioUploadComponent extends FieldType<FieldTypeConfig> implements OnInit, OnDestroy {
+ fileName = "";
+ errorMessage = "";
+ isUploading = false;
+ private localPreviewUrl = "";
+
+ ngOnInit(): void {
+ const value = this.formControl.value;
+ if (typeof value === "string" && value.trim().length > 0) {
+ this.fileName = this.getDisplayName(value);
+ // If the saved value is a server path, fetch the audio via HttpClient
+ // (which carries the JWT) and create a blob URL for the <audio> element.
+ if (!value.startsWith("data:audio/")) {
+ this.loadServerAudioPreview(value);
+ }
+ }
+ }
+
+ constructor(private http: HttpClient) {
+ super();
+ }
+
+ get previewSrc(): string {
+ if (this.localPreviewUrl) {
+ return this.localPreviewUrl;
+ }
+ const value = this.formControl.value;
+ if (typeof value !== "string" || value.trim().length === 0) {
+ return "";
+ }
+ if (value.startsWith("data:audio/")) {
+ return value;
+ }
+ // Server path — blob URL is created asynchronously via loadServerAudioPreview.
+ // Return empty until it's ready; the <audio> element is hidden when previewSrc is empty.
+ return "";
+ }
+
+ ngOnDestroy(): void {
+ this.revokePreviewUrl();
+ }
+
+ async onFileSelected(event: Event): Promise<void> {
+ if (this.isUploading) {
+ return;
+ }
+ this.errorMessage = "";
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+
+ if (!file) {
+ return;
+ }
+ if (!file.type.startsWith("audio/")) {
+ this.errorMessage = "Choose an audio file.";
+ input.value = "";
+ return;
+ }
+ this.revokePreviewUrl();
+ const previewUrl = URL.createObjectURL(file);
+ this.localPreviewUrl = previewUrl;
+ this.isUploading = true;
+
+ try {
+ const response = await firstValueFrom(
+ this.http.post<HuggingFaceAudioUploadResponse>(
+ `${AppSettings.getApiEndpoint()}/huggingface/upload-audio?filename=${encodeURIComponent(file.name)}`,
+ file,
+ {
+ headers: {
+ "Content-Type": "application/octet-stream",
+ },
+ }
+ )
+ );
+ // If the user clicked Clear while the upload was in flight,
+ // localPreviewUrl will have been revoked/reset — discard the stale response.
+ if (this.localPreviewUrl !== previewUrl) return;
+ this.fileName = response.fileName || file.name;
+ this.formControl.setValue(response.path);
+ if (typeof this.key === "string" && this.model) {
+ this.model[this.key] = response.path;
+ }
+ this.formControl.markAsDirty();
+ this.formControl.markAsTouched();
+ this.formControl.updateValueAndValidity();
+ } catch (err) {
+ console.error("Audio upload failed:", err);
+ if (this.localPreviewUrl !== previewUrl) return;
+ this.clearAudio(input, false);
+ this.errorMessage = "Could not upload this audio file.";
+ } finally {
+ this.isUploading = false;
+ }
+ }
+
+ clearAudio(input: HTMLInputElement, clearError: boolean = true): void {
+ this.fileName = "";
+ if (clearError) {
+ this.errorMessage = "";
+ }
+ this.isUploading = false;
+ this.revokePreviewUrl();
+ input.value = "";
+ this.formControl.setValue("");
+ if (typeof this.key === "string" && this.model) {
+ this.model[this.key] = "";
+ }
+ this.formControl.markAsDirty();
+ this.formControl.markAsTouched();
+ this.formControl.updateValueAndValidity();
+ }
+
+ private loadServerAudioPreview(serverPath: string): void {
+ firstValueFrom(
+ this.http.get(
+ `${AppSettings.getApiEndpoint()}/huggingface/audio-preview?path=${encodeURIComponent(serverPath)}`,
+ {
+ responseType: "blob",
+ }
+ )
+ )
+ .then(blob => {
+ // Guard against clear/re-upload racing with the fetch
+ if (this.formControl.value !== serverPath) return;
+ this.revokePreviewUrl();
+ this.localPreviewUrl = URL.createObjectURL(blob);
+ })
+ .catch((err: unknown) => {
+ console.error("Failed to load audio preview:", err);
+ if (this.formControl.value !== serverPath) return;
+ this.errorMessage = "Could not load audio preview.";
+ });
+ }
+
+ private revokePreviewUrl(): void {
+ if (this.localPreviewUrl) {
+ URL.revokeObjectURL(this.localPreviewUrl);
+ this.localPreviewUrl = "";
+ }
+ }
+
+ private getDisplayName(value: string): string {
+ const trimmedValue = value.trim();
+ if (!trimmedValue) {
+ return "";
+ }
+ if (trimmedValue.startsWith("data:audio/")) {
+ return "Selected audio";
+ }
+ const segments = trimmedValue.split(/[\\/]/);
+ return segments[segments.length - 1] || "Selected audio";
+ }
+}