Spaces:
Running
Running
| import { useReducer, useCallback, useRef } from "react"; | |
| import type { | |
| AppState, | |
| AppAction, | |
| OutputFormat, | |
| SourceKind, | |
| StemAsset, | |
| StemResult, | |
| } from "../types"; | |
| import { | |
| fetchExampleOutput, | |
| getAudioUrl, | |
| getDownloadAllUrl, | |
| getDownloadUrl, | |
| importUrl, | |
| uploadFile, | |
| startSeparation, | |
| subscribeProgress, | |
| } from "../api"; | |
| function reducer(state: AppState, action: AppAction): AppState { | |
| switch (action.type) { | |
| case "UPLOAD_START": | |
| return { phase: "uploading", progress: 0, message: action.message }; | |
| case "UPLOAD_PROGRESS": | |
| return state.phase === "uploading" | |
| ? { ...state, progress: action.progress } | |
| : state; | |
| case "UPLOAD_DONE": | |
| return { | |
| phase: "uploaded", | |
| jobId: action.jobId, | |
| filename: action.filename, | |
| originalUrl: action.originalUrl, | |
| outputFormat: action.outputFormat, | |
| songName: action.songName, | |
| sourceKind: action.sourceKind, | |
| sourceUrl: action.sourceUrl, | |
| resolvedUrl: action.resolvedUrl, | |
| }; | |
| case "SET_OUTPUT_FORMAT": | |
| return state.phase === "uploaded" | |
| ? { ...state, outputFormat: action.outputFormat } | |
| : state; | |
| case "SEPARATE_START": | |
| if (state.phase !== "uploaded") return state; | |
| return { | |
| phase: "separating", | |
| jobId: state.jobId, | |
| filename: state.filename, | |
| originalUrl: state.originalUrl, | |
| outputFormat: state.outputFormat, | |
| songName: state.songName, | |
| sourceKind: state.sourceKind, | |
| sourceUrl: state.sourceUrl, | |
| resolvedUrl: state.resolvedUrl, | |
| state: "queued", | |
| progress: 0, | |
| message: "Starting separation...", | |
| }; | |
| case "SEPARATE_PROGRESS": | |
| if (state.phase !== "separating") return state; | |
| return { | |
| ...state, | |
| state: action.state, | |
| progress: action.progress, | |
| message: action.message, | |
| }; | |
| case "SEPARATE_DONE": | |
| if (state.phase !== "separating") return state; | |
| return { | |
| phase: "done", | |
| jobId: state.jobId, | |
| filename: state.filename, | |
| originalUrl: state.originalUrl, | |
| outputFormat: state.outputFormat, | |
| songName: state.songName, | |
| sourceKind: state.sourceKind, | |
| sourceUrl: state.sourceUrl, | |
| resolvedUrl: state.resolvedUrl, | |
| stems: action.stems, | |
| downloadAllUrl: action.downloadAllUrl, | |
| }; | |
| case "LOAD_EXAMPLE_DONE": | |
| return { | |
| phase: "example", | |
| original: action.original, | |
| stems: action.stems, | |
| downloadAllUrl: action.downloadAllUrl, | |
| }; | |
| case "ERROR": | |
| return { phase: "error", message: action.message }; | |
| case "RESET": | |
| return { phase: "idle" }; | |
| default: | |
| return state; | |
| } | |
| } | |
| export function useSeparation() { | |
| const [state, dispatch] = useReducer(reducer, { phase: "idle" }); | |
| const cleanupRef = useRef<(() => void) | null>(null); | |
| const upload = useCallback(async (file: File) => { | |
| if (cleanupRef.current) { | |
| cleanupRef.current(); | |
| cleanupRef.current = null; | |
| } | |
| try { | |
| dispatch({ type: "UPLOAD_START", message: "Uploading audio file..." }); | |
| const result = await uploadFile(file, (progress) => { | |
| dispatch({ type: "UPLOAD_PROGRESS", progress }); | |
| }); | |
| dispatch({ | |
| type: "UPLOAD_DONE", | |
| jobId: result.job_id, | |
| filename: result.filename, | |
| originalUrl: getAudioUrl( | |
| result.job_id, | |
| `input${getExtFromFilename(result.filename)}` | |
| ), | |
| outputFormat: getDefaultOutputFormat(result.filename), | |
| songName: stripExtension(result.filename), | |
| sourceKind: "file", | |
| }); | |
| } catch (err) { | |
| dispatch({ | |
| type: "ERROR", | |
| message: err instanceof Error ? err.message : "Upload failed", | |
| }); | |
| } | |
| }, []); | |
| const separate = useCallback( | |
| async ( | |
| jobId: string, | |
| stems: string[], | |
| outputFormat: OutputFormat | |
| ) => { | |
| try { | |
| dispatch({ type: "SEPARATE_START" }); | |
| await startSeparation(jobId, stems, outputFormat); | |
| // Subscribe to progress | |
| cleanupRef.current = subscribeProgress( | |
| jobId, | |
| (event) => { | |
| dispatch({ | |
| type: "SEPARATE_PROGRESS", | |
| state: event.state, | |
| progress: event.progress, | |
| message: event.message, | |
| }); | |
| }, | |
| (stemResults) => { | |
| dispatch({ | |
| type: "SEPARATE_DONE", | |
| stems: resolveJobStemAssets(jobId, stemResults), | |
| downloadAllUrl: getDownloadAllUrl(jobId), | |
| }); | |
| }, | |
| (error) => { | |
| dispatch({ type: "ERROR", message: error }); | |
| } | |
| ); | |
| } catch (err) { | |
| dispatch({ | |
| type: "ERROR", | |
| message: | |
| err instanceof Error ? err.message : "Separation failed", | |
| }); | |
| } | |
| }, | |
| [] | |
| ); | |
| const importFromUrl = useCallback(async (url: string) => { | |
| if (cleanupRef.current) { | |
| cleanupRef.current(); | |
| cleanupRef.current = null; | |
| } | |
| try { | |
| dispatch({ type: "UPLOAD_START", message: "Downloading source track..." }); | |
| const result = await importUrl(url); | |
| dispatch({ | |
| type: "UPLOAD_DONE", | |
| jobId: result.job_id, | |
| filename: result.filename, | |
| originalUrl: getAudioUrl( | |
| result.job_id, | |
| `input${getExtFromFilename(result.filename)}` | |
| ), | |
| outputFormat: getDefaultOutputFormat(result.filename), | |
| songName: result.title || stripExtension(result.filename), | |
| sourceKind: result.platform, | |
| sourceUrl: result.source_url, | |
| resolvedUrl: result.resolved_url, | |
| }); | |
| } catch (err) { | |
| dispatch({ | |
| type: "ERROR", | |
| message: err instanceof Error ? err.message : "Import failed", | |
| }); | |
| } | |
| }, []); | |
| const loadExample = useCallback(async () => { | |
| if (cleanupRef.current) { | |
| cleanupRef.current(); | |
| cleanupRef.current = null; | |
| } | |
| try { | |
| const example = await fetchExampleOutput(); | |
| dispatch({ | |
| type: "LOAD_EXAMPLE_DONE", | |
| original: { | |
| ...example.original, | |
| songName: example.song || example.original.songName, | |
| }, | |
| stems: example.stems, | |
| downloadAllUrl: example.downloadAllUrl, | |
| }); | |
| } catch (err) { | |
| dispatch({ | |
| type: "ERROR", | |
| message: | |
| err instanceof Error ? err.message : "Failed to load example output", | |
| }); | |
| } | |
| }, []); | |
| const setOutputFormat = useCallback((outputFormat: OutputFormat) => { | |
| dispatch({ type: "SET_OUTPUT_FORMAT", outputFormat }); | |
| }, []); | |
| const reset = useCallback(() => { | |
| if (cleanupRef.current) { | |
| cleanupRef.current(); | |
| cleanupRef.current = null; | |
| } | |
| dispatch({ type: "RESET" }); | |
| }, []); | |
| return { | |
| state, | |
| upload, | |
| importFromUrl, | |
| separate, | |
| loadExample, | |
| setOutputFormat, | |
| reset, | |
| }; | |
| } | |
| function getExtFromFilename(filename: string): string { | |
| const dot = filename.lastIndexOf("."); | |
| return dot >= 0 ? filename.slice(dot) : ".wav"; | |
| } | |
| function stripExtension(filename: string): string { | |
| const dot = filename.lastIndexOf("."); | |
| return dot >= 0 ? filename.slice(0, dot) : filename; | |
| } | |
| function getDefaultOutputFormat(filename: string): OutputFormat { | |
| const ext = getExtFromFilename(filename).toLowerCase(); | |
| if (ext === ".wav") return "wav"; | |
| if (ext === ".mp3") return "mp3"; | |
| if (ext === ".aac") return "aac"; | |
| return "wav"; | |
| } | |
| function resolveJobStemAssets(jobId: string, stems: StemResult[]): StemAsset[] { | |
| return stems.map((stem) => ({ | |
| ...stem, | |
| audioUrl: getAudioUrl(jobId, stem.filename), | |
| downloadUrl: getDownloadUrl(jobId, stem.filename), | |
| })); | |
| } | |
| export function getSourceLabel(sourceKind: SourceKind): string { | |
| switch (sourceKind) { | |
| case "youtube": | |
| return "YouTube import"; | |
| case "ytmusic": | |
| return "YouTube Music import"; | |
| case "spotify": | |
| return "Spotify import"; | |
| default: | |
| return "Uploaded file"; | |
| } | |
| } | |