forked from MaxMaeder/OpenPin
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaudio.ts
More file actions
159 lines (138 loc) · 5.09 KB
/
Copy pathaudio.ts
File metadata and controls
159 lines (138 loc) · 5.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import { PassThrough } from "stream";
import ffmpeg from "fluent-ffmpeg";
import streamifier from "streamifier";
export const changeVolume = (inputBuffer: Buffer, volume: number): Promise<Buffer> => {
return new Promise((resolve, reject) => {
const inputStream = streamifier.createReadStream(inputBuffer);
const outputStream = new PassThrough();
const chunks: Buffer[] = [];
ffmpeg(inputStream)
.inputFormat("ogg")
.audioFilters(`volume=${volume}`)
.format("ogg")
.on("error", reject)
.on("end", () => {
resolve(Buffer.concat(chunks));
})
.pipe(outputStream, { end: true });
outputStream.on("data", (chunk) => chunks.push(chunk));
outputStream.on("error", reject);
});
};
export interface TranscodeOptions {
/** Output sample rate in Hz (default 16 kHz, like Azure TTS). */
sampleRate?: number;
/** Output channel count (default mono). */
channels?: number;
}
/*
* Transcode any ffmpeg-readable audio buffer to an Ogg buffer with the given
* sample rate / channel count. Used to normalize third-party TTS output to
* the profile the device pipeline expects (Ogg, 16 kHz, mono).
*/
export const transcodeToOgg = (
inputBuffer: Buffer,
{ sampleRate = 16000, channels = 1 }: TranscodeOptions = {}
): Promise<Buffer> => {
return new Promise((resolve, reject) => {
const inputStream = streamifier.createReadStream(inputBuffer);
const outputStream = new PassThrough();
const chunks: Buffer[] = [];
ffmpeg(inputStream)
.audioFrequency(sampleRate)
.audioChannels(channels)
.format("ogg")
.on("error", reject)
.pipe(outputStream, { end: true });
outputStream.on("data", (chunk) => chunks.push(chunk));
// Resolve on the output stream's end, not ffmpeg's — ffmpeg can signal
// "end" while the last chunks are still buffered in the PassThrough.
outputStream.on("end", () => resolve(Buffer.concat(chunks)));
outputStream.on("error", reject);
});
};
export const getPeakVolume = (inputBuffer: Buffer): Promise<number> => {
return new Promise((resolve, reject) => {
const inputStream = streamifier.createReadStream(inputBuffer);
let stderr = "";
ffmpeg(inputStream)
.inputFormat("ogg")
.audioFilters("volumedetect")
.format("null")
.outputOptions("-f", "null")
.on("stderr", (line: string) => {
stderr += line + "\n";
})
.on("end", () => {
const match = stderr.match(/max_volume: ([\-\d\.]+) dB/);
if (match) {
resolve(parseFloat(match[1]));
} else {
reject(new Error("Failed to parse max volume from ffmpeg output."));
}
})
.on("error", reject)
.saveToFile("/dev/null");
});
};
export interface BackgroundAudioOptions {
/** Seconds into the background track to start mixing (default 0 s). */
startOffset?: number;
/** Background track volume (0 – 1, default 0.5 = 50 %). */
volume?: number;
/** Silence before the foreground starts (lead‑in, default 0 s). */
preDelay?: number;
/** Silence after the foreground ends (tail‑out, default 0 s). */
postDelay?: number;
}
/**
* Mix an OGG buffer with a background‑audio OGG file.
*/
export const addBackgroundAudio = (
inputBuffer: Buffer,
bgPath: string,
{ startOffset = 0, volume = 0.5, preDelay = 0, postDelay = 0 }: BackgroundAudioOptions = {}
): Promise<Buffer> =>
new Promise((resolve, reject) => {
const fgStream = streamifier.createReadStream(inputBuffer);
const outStream = new PassThrough();
const chunks: Buffer[] = [];
/* ---------- foreground filter chain --------------------------- */
const fgFilters: string[] = [];
// Lead‑in silence
if (preDelay > 0) {
const ms = Math.round(preDelay * 1000);
fgFilters.push(`adelay=delays=${ms}|${ms}:all=1`);
}
// Tail‑out silence
if (postDelay > 0) {
fgFilters.push(`apad=pad_dur=${postDelay}`);
}
// If we added any FG filters, label the output [fg]
const fgLine = fgFilters.length ? `[0:a]${fgFilters.join(",")}[fg]` : "";
const fgLabel = fgFilters.length ? "[fg]" : "[0:a]";
/* ---------- background volume --------------------------------- */
const bgLine = `[1:a]volume=${volume}[bg]`;
/* ---------- final mix (stop with FG) -------------------------- */
const mixLine =
`${fgLabel}[bg]amix=` +
`inputs=2:` +
`duration=first:` + // stop when foreground ends
`dropout_transition=0:` +
`normalize=0[mix]`;
const filterGraph = [fgLine, bgLine, mixLine].filter(Boolean).join(";");
/* ---------- run ffmpeg ---------------------------------------- */
ffmpeg()
.input(fgStream)
.inputFormat("ogg")
.input(bgPath)
.inputOptions([`-ss ${startOffset}`]) // seek into background
.complexFilter(filterGraph)
.outputOptions(`-map [mix]`) // map the mixed audio
.format("ogg")
.on("error", reject)
.on("end", () => resolve(Buffer.concat(chunks)))
.pipe(outStream, { end: true });
outStream.on("data", (c) => chunks.push(c));
outStream.on("error", reject);
});