convert flacs to aac for streaming

This commit is contained in:
2026-06-16 12:49:30 +02:00
parent f5e8b87846
commit 7aa1746f97
12 changed files with 401 additions and 8 deletions

View File

@@ -0,0 +1,100 @@
'use client'
import { useState } from "react";
import { AudioLines, Loader2, RefreshCw } from "lucide-react";
import { Button } from "~/components/ui/button";
import { trpc } from "~/app/_trpc/Client";
import { useUploadThing } from "~/lib/uploadthing";
import { transcodeToAac } from "~/lib/ffmpeg/transcode";
import { toast } from "sonner";
import type { RouterOutputs } from "~/server/routers/_app";
import type { IterableElement } from "type-fest";
export default function ConvertToStreamButton(props: {
track: IterableElement<RouterOutputs['music']['list']>;
}) {
const { track } = props;
const utils = trpc.useUtils();
const [busy, setBusy] = useState(false);
const [stage, setStage] = useState("");
const [progress, setProgress] = useState(0);
const setStream = trpc.music.setStream.useMutation({
onSuccess: () => utils.music.list.invalidate(),
});
const { startUpload } = useUploadThing("musicUploader");
async function handleConvert() {
setBusy(true);
setProgress(0);
let currentStage = "Loading ffmpeg";
const goto = (s: string) => {
currentStage = s;
setStage(s);
};
try {
goto("Transcoding");
const file = await transcodeToAac({
sourceUrl: track.fileUrl,
outputName: `${track.title || "track"}.m4a`,
onProgress: setProgress,
});
goto("Uploading");
const uploaded = await startUpload([file]);
const res = uploaded?.[0];
if (!res) throw new Error("Upload returned no file");
goto("Saving");
await setStream.mutateAsync({
id: track.id,
streamUrl: res.serverData.fileUrl,
streamKey: res.serverData.fileKey,
streamName: res.serverData.fileName,
});
toast("Streaming version saved");
} catch (e) {
console.error("[ConvertToStream] failed during", currentStage, e);
const detail =
e instanceof Error
? e.message
: typeof e === "string"
? e
: (() => {
try {
return JSON.stringify(e);
} catch {
return String(e);
}
})();
toast(`Conversion failed (${currentStage}): ${detail || "see console for details"}`);
} finally {
setBusy(false);
setStage("");
setProgress(0);
}
}
return (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={handleConvert}>
{busy ? (
<Loader2 className="animate-spin" />
) : track.streamUrl ? (
<RefreshCw />
) : (
<AudioLines />
)}
{busy
? `${stage}${stage === "Transcoding" && progress ? ` ${Math.round(progress * 100)}%` : "…"}`
: track.streamUrl
? "Re-generate stream"
: "Generate stream (AAC)"}
</Button>
{track.streamUrl && !busy && (
<span className="text-xs text-muted-foreground">Streaming version ready</span>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm";
import ConvertToStreamButton from "./_components/ConvertToStreamButton";
import { CollapsibleForm } from "~/app/_components/Form/Components";
import { useEffect } from "react";
@@ -14,10 +15,11 @@ export default function AdminMusicPage() {
{tracks && <>
{tracks.map((t) => (
<Card.Card key={t.id}>
<Card.CardContent>
<Card.CardContent className="flex flex-col gap-4">
<UploadMusicForm entity={t} className="w-full"/>
<ConvertToStreamButton track={t} />
</Card.CardContent>
</Card.Card>
</Card.Card>
))}
</>}
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>

View File

@@ -0,0 +1,125 @@
'use client'
import { useRef, useState } from "react";
import { Download, Loader2, Pause, Play } from "lucide-react";
import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function AudioPlayer(props: {
/** Streaming-friendly source the player actually plays. */
src: string;
/** Original high-quality file offered via the download button. */
downloadUrl: string;
downloadName: string;
}) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [seeking, setSeeking] = useState(false);
const [downloading, setDownloading] = useState(false);
function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}
async function handleDownload() {
setDownloading(true);
try {
// The download file is cross-origin, so the <a download> attribute is
// ignored — fetch it as a blob to force a real download.
const res = await fetch(props.downloadUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = props.downloadName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
toast(`Download failed: ${e instanceof Error ? e.message : "unknown error"}`);
} finally {
setDownloading(false);
}
}
return (
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
<audio
ref={audioRef}
src={props.src}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onTimeUpdate={(e) => {
if (!seeking) setCurrent(e.currentTarget.currentTime);
}}
onEnded={() => setPlaying(false)}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={playing ? "Pause" : "Play"}
onClick={togglePlay}
>
{playing ? <Pause /> : <Play />}
</Button>
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(current)}
</span>
<Slider
className="flex-1"
min={0}
max={duration || 1}
step={0.1}
value={[current]}
onValueChange={([v]) => {
setSeeking(true);
setCurrent(v ?? 0);
}}
onValueCommit={([v]) => {
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
setSeeking(false);
}}
/>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Download lossless file"
title="Download lossless file"
disabled={downloading}
onClick={handleDownload}
>
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
</Button>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Spinner } from "~/components/ui/spinner";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import AudioPlayer from "./_components/AudioPlayer";
export default function MusicPage() {
const { data: tracks, isLoading } = trpc.music.list.useQuery();
useTimeLine(tracks)
@@ -41,9 +42,11 @@ export default function MusicPage() {
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)}
<AnimatePopUp position={i + 1.3}>
<audio controls className="w-full player" src={track.fileUrl}>
Your browser does not support the audio element.
</audio>
<AudioPlayer
src={track.streamUrl ?? track.fileUrl}
downloadUrl={track.fileUrl}
downloadName={track.fileName}
/>
</AnimatePopUp>
</Card.CardContent>
</Card.AnimatedCard>

114
src/lib/ffmpeg/transcode.ts Normal file
View File

@@ -0,0 +1,114 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
// Single-threaded core: works without SharedArrayBuffer, so no COOP/COEP
// cross-origin-isolation headers are required. Slower than the MT core, which
// is fine for occasional admin-side transcodes.
const CORE_VERSION = "0.12.10";
// @ffmpeg/ffmpeg 0.12.x always spawns a `type: "module"` worker. A module
// worker can't `importScripts`, so the worker dynamically `import()`s the core —
// which means the core must be the ESM build, not UMD.
const CORE_BASE = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/esm`;
// Self-hosted worker, bundled from @ffmpeg/ffmpeg's ESM `worker.js` via the
// `build:ffmpeg-worker` package script (rerun it when bumping @ffmpeg/ffmpeg).
// The shipped UMD worker is unusable here: its
// dynamic import is webpack-compiled into a stub that throws MODULE_NOT_FOUND,
// and letting Next bundle the worker hits the same problem. Serving our own
// same-origin module worker keeps the native `import()` intact.
// Must be an absolute URL: the library resolves classWorkerURL against
// `import.meta.url`, which is a file:// base under Next, so a root-relative path
// would wrongly become `file:///ffmpeg/worker.js`.
const WORKER_PATH = "/ffmpeg/worker.js";
let instance: FFmpeg | null = null;
let loadPromise: Promise<FFmpeg> | null = null;
function load(onLog?: (msg: string) => void): Promise<FFmpeg> {
if (instance) return Promise.resolve(instance);
if (!loadPromise) {
loadPromise = (async () => {
const ffmpeg = new FFmpeg();
ffmpeg.on("log", ({ message }) => {
console.debug("[ffmpeg]", message);
onLog?.(message);
});
const ok = await ffmpeg.load({
coreURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
classWorkerURL: new URL(WORKER_PATH, window.location.origin).href,
});
if (!ok) throw new Error("ffmpeg.load() returned false — core failed to initialise");
instance = ffmpeg;
return ffmpeg;
})();
// If loading fails, clear the cached promise so the next click retries.
loadPromise.catch(() => {
loadPromise = null;
});
}
return loadPromise;
}
/**
* Transcodes a (FLAC) source URL to a streaming-friendly AAC/m4a file in the
* browser. Returns a File ready to hand to UploadThing's startUpload.
*/
export async function transcodeToAac(opts: {
sourceUrl: string;
outputName?: string;
bitrate?: string;
onProgress?: (ratio: number) => void;
onLog?: (msg: string) => void;
}): Promise<File> {
let lastLog = "";
const onLog = (msg: string) => {
if (msg.trim()) lastLog = msg;
opts.onLog?.(msg);
};
const ffmpeg = await load(onLog);
// `load` only wires the persisted instance's log handler on first init; make
// sure this call's logs are captured even when the instance was cached.
const logHandler = ({ message }: { message: string }) => onLog(message);
ffmpeg.on("log", logHandler);
const progressHandler = opts.onProgress
? ({ progress }: { progress: number }) =>
opts.onProgress?.(Math.min(1, Math.max(0, progress)))
: null;
if (progressHandler) ffmpeg.on("progress", progressHandler);
try {
// fetchFile pulls the source cross-origin; surface a clear message if the
// host (UploadThing) blocks it.
let sourceData: Uint8Array;
try {
sourceData = await fetchFile(opts.sourceUrl);
} catch (e) {
throw new Error(
`Could not fetch source file (CORS or network): ${e instanceof Error ? e.message : String(e)}`,
);
}
await ffmpeg.writeFile("input.flac", sourceData);
const code = await ffmpeg.exec([
"-i", "input.flac",
"-c:a", "aac",
"-b:a", opts.bitrate ?? "192k",
// Move the moov atom to the front so the player can start before the
// whole file has downloaded — the whole point of the stream version.
"-movflags", "+faststart",
"output.m4a",
]);
if (code !== 0) {
throw new Error(`ffmpeg exited with code ${code}: ${lastLog || "no log output"}`);
}
const data = await ffmpeg.readFile("output.m4a");
await ffmpeg.deleteFile("input.flac");
await ffmpeg.deleteFile("output.m4a");
// Copy into a fresh ArrayBuffer-backed view so the Blob typing is happy
// (readFile may be typed against a SharedArrayBuffer-backed Uint8Array).
const bytes = new Uint8Array(data as Uint8Array);
const blob = new Blob([bytes], { type: "audio/mp4" });
return new File([blob], opts.outputName ?? "stream.m4a", { type: "audio/mp4" });
} finally {
ffmpeg.off("log", logHandler);
if (progressHandler) ffmpeg.off("progress", progressHandler);
}
}

View File

@@ -7,6 +7,9 @@ export const createMusicInputSchema = z.object({
fileUrl: z.string(),
fileKey: z.string(),
fileName: z.string(),
streamUrl: z.string().optional().nullable(),
streamKey: z.string().optional().nullable(),
streamName: z.string().optional().nullable(),
})
export const updateMusicInputSchema = z.object({
id: z.string().uuid(),
@@ -15,4 +18,14 @@ export const updateMusicInputSchema = z.object({
fileUrl: z.string().optional(),
fileKey: z.string().optional(),
fileName: z.string().optional(),
streamUrl: z.string().optional().nullable(),
streamKey: z.string().optional().nullable(),
streamName: z.string().optional().nullable(),
})
export const setStreamInputSchema = z.object({
id: z.string().uuid(),
streamUrl: z.string(),
streamKey: z.string(),
streamName: z.string(),
})

View File

@@ -1,5 +1,7 @@
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
import { generateReactHelpers, generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
import type { FileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton<FileRouter>();
export const UploadDropzone = generateUploadDropzone<FileRouter>();
export const { useUploadThing } = generateReactHelpers<FileRouter>();

View File

@@ -94,9 +94,14 @@ export const music = createTable(
id: d.uuid().primaryKey().notNull(),
title: d.varchar({ length: 100 }).notNull(),
description: d.text(),
// Original high-quality upload (e.g. FLAC), offered as a download.
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
fileName: d.varchar("file_name", { length: 200 }).notNull(),
// Lighter, streaming-friendly transcode (e.g. AAC) used by the player.
streamUrl: d.varchar("stream_url", { length: 500 }),
streamKey: d.varchar("stream_key", { length: 200 }),
streamName: d.varchar("stream_name", { length: 200 }),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)

View File

@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
import { isAdmin } from "~/app/actions";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema } from "~/lib/trpc/music/schemas";
import { utapi } from "../uploadthing";
export const musicRouter = router({
list: publicProcedure.query(async () => {
@@ -33,6 +33,22 @@ export const musicRouter = router({
const { id, ...data } = input;
return db.update(music).set(data).where(eq(music.id, id)).returning();
}),
setStream: publicProcedure
.input(setStreamInputSchema)
.mutation(async ({ input }) => {
const admin = await isAdmin();
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
const { id, ...data } = input;
const existing = await db.select().from(music).where(eq(music.id, id)).limit(1);
const prev = existing.at(0);
if (!prev) throw new TRPCError({ code: "NOT_FOUND", message: "Track not found" });
const res = await db.update(music).set(data).where(eq(music.id, id)).returning();
// Drop the previous stream transcode so we don't orphan it on UploadThing.
if (prev.streamKey && prev.streamKey !== data.streamKey) {
utapi.deleteFiles(prev.streamKey);
}
return res.at(0);
}),
delete: publicProcedure
.input(z.object({id:z.string().uuid()}))
.mutation(async ({ input }) => {
@@ -41,7 +57,8 @@ export const musicRouter = router({
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
let ret = res.at(0)
if (ret) {
utapi.deleteFiles(ret.fileKey)
const keys = [ret.fileKey, ret.streamKey].filter((k): k is string => !!k);
utapi.deleteFiles(keys)
}
return ret;
}),