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";
+  }
+}


Reply via email to