feat(media): loop mode + high-quality OggOpus music playback
- Loop: toggle via POST /api/media/loop → COMMAND_MEDIA_LOOP; gateway replays finished music track on natural end (queue untouched); status payload exposes loop flag; FE tombol Loop di music-player + mini-player. - Kualitas suara: music playback sekarang di-transcode sekali via ffmpeg ke OggOpus 48kHz stereo 192kbps dengan volume di-bake ke encode — menghindari double lossy encode (inlineVolume) yang bikin suara buram. Screen share tetap pakai jalur lama. - Backend: MediaState.loop, setLoop service, route + schema validation.
This commit is contained in:
@@ -33,6 +33,71 @@ export interface ResolveOptions {
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export interface TranscodeResult {
|
||||
stream: Readable;
|
||||
/** Kill the ffmpeg child (used on stop/skip). */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode a source stream to high-quality OggOpus (48kHz stereo, 192kbps).
|
||||
*
|
||||
* Discord voice downmixes whatever we feed it to the channel's bitrate, so the
|
||||
* best we can do is hand it a clean 48kHz stereo Opus stream instead of the
|
||||
* raw source (which may be mono, low-bitrate, or a non-Opus container). The
|
||||
* volume is baked into the encode with `-af volume=` so the player does not
|
||||
* need inlineVolume re-encoding (double lossy encode).
|
||||
*/
|
||||
export function transcodeToHighQualityOgg(
|
||||
input: Readable,
|
||||
volume: number,
|
||||
): TranscodeResult {
|
||||
const proc = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vn",
|
||||
"-ac",
|
||||
"2",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-af",
|
||||
`volume=${volume}`,
|
||||
"-f",
|
||||
"ogg",
|
||||
"pipe:1",
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "ignore"] },
|
||||
);
|
||||
|
||||
input.pipe(proc.stdin);
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const cleanup = () => {
|
||||
activeProcesses.delete(proc);
|
||||
if (proc.exitCode === null) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
proc.once("exit", () => activeProcesses.delete(proc));
|
||||
|
||||
// If ffmpeg fails, surface the error to the consumer stream so the
|
||||
// AudioPlayer's error handler can advance the queue.
|
||||
const output = proc.stdout;
|
||||
output.on("error", () => cleanup());
|
||||
|
||||
return { stream: output, cleanup };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
musicVolume: number;
|
||||
loop: boolean;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user