This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new fcb9710a1a feat(frontend): add HuggingFace audio upload component
(#5567)
fcb9710a1a is described below
commit fcb9710a1a40e5996a451ba823c0fc3abc61dfbd
Author: Elliot Lin <[email protected]>
AuthorDate: Mon Jun 29 15:20:31 2026 -0700
feat(frontend): add HuggingFace audio upload component (#5567)
### 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
- Authenticated audio preview/playback — fetches server-stored audio via
`HttpClient` (which carries the JWT) and creates a blob URL for the
`<audio>` element, avoiding 401s on workflow reload
- Local audio preview using a browser Object URL while upload is in
progress
- Stale response guards — discards upload results and preview fetches if
the user clears the field while a request is in flight
- Concurrent upload protection — disables the file input and Clear
button during upload
- 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 also registers `HuggingFaceComponent` and
`HuggingFaceAudioUploadComponent` in `formly-config.ts` and declares
them in `AppModule`. 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?
38 unit tests in `hugging-face-audio-upload.component.spec.ts` covering:
- `ngOnInit` — filename extraction from server paths, data URLs,
empty/whitespace values
- `previewSrc` — returns data URLs as-is, empty for server paths (loaded
async), empty for blank values
- `onFileSelected` — successful upload, non-audio rejection, concurrent
upload guard, no-file guard, upload-in-progress state, error handling,
filename fallback, model updates, Content-Type/URL encoding
- `loadServerAudioPreview` — successful blob fetch, fetch failure error
message, stale response discard on both success and error, no-fetch for
data URLs, URL encoding
- `clearAudio` — full state reset, error preservation, model clearing,
dirty/touched marking
- Stale upload guards — discard success/error when cleared during flight
- `ngOnDestroy` — blob URL revocation
- `getDisplayName` — forward/backslash paths, trailing separator, flat
filename
Run with `ng test`.
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.6
---------
Co-authored-by: Elliot <[email protected]>
Co-authored-by: Anish Shivamurthy <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
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";
+ }
+}