@series-inc/stowkit-cli 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { AacQuality } from '../core/types.js';
|
|
2
2
|
import type { IAacEncoder, IAudioDecoder, DecodedPcm } from './interfaces.js';
|
|
3
3
|
export declare class NodeAacEncoder implements IAacEncoder {
|
|
4
|
-
private ffmpegPath;
|
|
5
4
|
initialize(): Promise<void>;
|
|
6
5
|
encode(channels: Float32Array[], sampleRate: number, quality: AacQuality): Promise<Uint8Array>;
|
|
7
6
|
}
|
|
8
7
|
export declare class NodeAudioDecoder implements IAudioDecoder {
|
|
9
|
-
private ffmpegPath;
|
|
10
8
|
initialize(): Promise<void>;
|
|
11
9
|
decodeToPcm(audioData: Uint8Array, _fileName: string): Promise<DecodedPcm>;
|
|
12
10
|
}
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
import * as fs from 'node:fs/promises';
|
|
4
|
-
import * as os from 'node:os';
|
|
5
|
-
import * as path from 'node:path';
|
|
6
1
|
import { AacQuality } from '../core/types.js';
|
|
7
|
-
|
|
2
|
+
import { createFFmpegAudio } from '@strangeape/ffmpeg-audio-wasm';
|
|
8
3
|
// ─── Quality mapping ────────────────────────────────────────────────────────
|
|
9
4
|
const QUALITY_TO_VBR = {
|
|
10
5
|
[AacQuality.Lowest]: '0.1',
|
|
@@ -44,136 +39,89 @@ function createWavBuffer(channels, sampleRate) {
|
|
|
44
39
|
}
|
|
45
40
|
return new Uint8Array(buffer);
|
|
46
41
|
}
|
|
47
|
-
// ───
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
42
|
+
// ─── Parse audio info from ffmpeg stderr ────────────────────────────────────
|
|
43
|
+
function parseAudioInfo(stderr) {
|
|
44
|
+
let sampleRate = 44100;
|
|
45
|
+
let numChannels = 2;
|
|
46
|
+
const srMatch = stderr.match(/(\d+)\s*Hz/);
|
|
47
|
+
if (srMatch)
|
|
48
|
+
sampleRate = parseInt(srMatch[1]);
|
|
49
|
+
const chMatch = stderr.match(/(\d+)\s*channels/) || stderr.match(/(mono|stereo)/i);
|
|
50
|
+
if (chMatch) {
|
|
51
|
+
if (chMatch[1] === 'mono')
|
|
52
|
+
numChannels = 1;
|
|
53
|
+
else if (chMatch[1] === 'stereo')
|
|
54
|
+
numChannels = 2;
|
|
55
|
+
else
|
|
56
|
+
numChannels = parseInt(chMatch[1]) || 2;
|
|
55
57
|
}
|
|
56
|
-
|
|
57
|
-
// Fallback to PATH
|
|
58
|
-
}
|
|
59
|
-
return 'ffmpeg';
|
|
58
|
+
return { sampleRate, numChannels };
|
|
60
59
|
}
|
|
61
60
|
// ─── NodeAacEncoder ─────────────────────────────────────────────────────────
|
|
62
61
|
export class NodeAacEncoder {
|
|
63
|
-
|
|
64
|
-
async initialize() {
|
|
65
|
-
this.ffmpegPath = await getFfmpegPath();
|
|
66
|
-
}
|
|
62
|
+
async initialize() { }
|
|
67
63
|
async encode(channels, sampleRate, quality) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'-q:a', vbr,
|
|
82
|
-
outFile,
|
|
83
|
-
]);
|
|
84
|
-
const result = await fs.readFile(outFile);
|
|
85
|
-
return new Uint8Array(result.buffer, result.byteOffset, result.byteLength);
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
64
|
+
const ffmpeg = await createFFmpegAudio();
|
|
65
|
+
const wavBytes = createWavBuffer(channels, sampleRate);
|
|
66
|
+
ffmpeg.writeFile('/input.wav', wavBytes);
|
|
67
|
+
const vbr = QUALITY_TO_VBR[quality];
|
|
68
|
+
const code = await ffmpeg.exec([
|
|
69
|
+
'-y',
|
|
70
|
+
'-i', '/input.wav',
|
|
71
|
+
'-c:a', 'aac',
|
|
72
|
+
'-q:a', vbr,
|
|
73
|
+
'/output.m4a',
|
|
74
|
+
]);
|
|
75
|
+
if (code !== 0) {
|
|
76
|
+
throw new Error(`FFmpeg AAC encode failed (code ${code}): ${ffmpeg.getLastStderr()}`);
|
|
89
77
|
}
|
|
78
|
+
return ffmpeg.readFile('/output.m4a');
|
|
90
79
|
}
|
|
91
80
|
}
|
|
92
81
|
// ─── NodeAudioDecoder ───────────────────────────────────────────────────────
|
|
93
82
|
export class NodeAudioDecoder {
|
|
94
|
-
|
|
95
|
-
async initialize() {
|
|
96
|
-
this.ffmpegPath = await getFfmpegPath();
|
|
97
|
-
}
|
|
83
|
+
async initialize() { }
|
|
98
84
|
async decodeToPcm(audioData, _fileName) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
'-y',
|
|
121
|
-
'-i', inFile,
|
|
122
|
-
'-f', 'f32le',
|
|
123
|
-
'-acodec', 'pcm_f32le',
|
|
124
|
-
outFile,
|
|
125
|
-
]);
|
|
126
|
-
// Get the actual sample rate and channel count via a second pass
|
|
127
|
-
const probeResult = await execFileAsync(this.ffmpegPath, [
|
|
128
|
-
'-i', inFile,
|
|
129
|
-
'-show_entries', 'stream=sample_rate,channels',
|
|
130
|
-
'-of', 'json',
|
|
131
|
-
'-v', 'quiet',
|
|
132
|
-
]).catch(() => null);
|
|
133
|
-
let sampleRate = 44100;
|
|
134
|
-
let numChannels = 2;
|
|
135
|
-
if (probeResult) {
|
|
136
|
-
try {
|
|
137
|
-
const info = JSON.parse(probeResult.stdout);
|
|
138
|
-
if (info.streams?.[0]) {
|
|
139
|
-
sampleRate = parseInt(info.streams[0].sample_rate) || 44100;
|
|
140
|
-
numChannels = info.streams[0].channels || 2;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// Use ffmpeg stderr parsing fallback
|
|
145
|
-
if (sampleRateMatch)
|
|
146
|
-
sampleRate = parseInt(sampleRateMatch[1]);
|
|
147
|
-
if (channelsMatch) {
|
|
148
|
-
if (channelsMatch[1] === 'mono')
|
|
149
|
-
numChannels = 1;
|
|
150
|
-
else if (channelsMatch[1] === 'stereo')
|
|
151
|
-
numChannels = 2;
|
|
152
|
-
else
|
|
153
|
-
numChannels = parseInt(channelsMatch[1]) || 2;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const rawBuf = await fs.readFile(outFile);
|
|
158
|
-
const rawF32 = new Float32Array(rawBuf.buffer, rawBuf.byteOffset, rawBuf.byteLength / 4);
|
|
159
|
-
const numSamples = Math.floor(rawF32.length / numChannels);
|
|
160
|
-
// Deinterleave
|
|
161
|
-
const channels = [];
|
|
162
|
-
for (let ch = 0; ch < numChannels; ch++) {
|
|
163
|
-
const channelData = new Float32Array(numSamples);
|
|
164
|
-
for (let i = 0; i < numSamples; i++) {
|
|
165
|
-
channelData[i] = rawF32[i * numChannels + ch];
|
|
166
|
-
}
|
|
167
|
-
channels.push(channelData);
|
|
168
|
-
}
|
|
169
|
-
return {
|
|
170
|
-
channels,
|
|
171
|
-
sampleRate,
|
|
172
|
-
durationMs: Math.round((numSamples / sampleRate) * 1000),
|
|
173
|
-
};
|
|
85
|
+
// First pass: probe audio info via a null mux
|
|
86
|
+
const probeFFmpeg = await createFFmpegAudio();
|
|
87
|
+
probeFFmpeg.writeFile('/input', audioData);
|
|
88
|
+
await probeFFmpeg.exec([
|
|
89
|
+
'-i', '/input',
|
|
90
|
+
'-f', 'null',
|
|
91
|
+
'-',
|
|
92
|
+
]);
|
|
93
|
+
const { sampleRate, numChannels } = parseAudioInfo(probeFFmpeg.getLastStderr());
|
|
94
|
+
// Second pass: decode to f32le WAV (ffmpeg 4.4 needs WAV muxer for pcm_f32le output)
|
|
95
|
+
const ffmpeg = await createFFmpegAudio();
|
|
96
|
+
ffmpeg.writeFile('/input', audioData);
|
|
97
|
+
const code = await ffmpeg.exec([
|
|
98
|
+
'-y',
|
|
99
|
+
'-i', '/input',
|
|
100
|
+
'-f', 'wav',
|
|
101
|
+
'-acodec', 'pcm_f32le',
|
|
102
|
+
'/output.wav',
|
|
103
|
+
]);
|
|
104
|
+
if (code !== 0) {
|
|
105
|
+
throw new Error(`FFmpeg decode failed (code ${code}): ${ffmpeg.getLastStderr()}`);
|
|
174
106
|
}
|
|
175
|
-
|
|
176
|
-
|
|
107
|
+
const wavBuf = ffmpeg.readFile('/output.wav');
|
|
108
|
+
// Skip 44-byte WAV header to get raw PCM f32le data
|
|
109
|
+
const rawBuf = wavBuf.slice(44);
|
|
110
|
+
const rawF32 = new Float32Array(rawBuf.buffer, rawBuf.byteOffset, rawBuf.byteLength / 4);
|
|
111
|
+
const numSamples = Math.floor(rawF32.length / numChannels);
|
|
112
|
+
// Deinterleave
|
|
113
|
+
const deinterleaved = [];
|
|
114
|
+
for (let ch = 0; ch < numChannels; ch++) {
|
|
115
|
+
const channelData = new Float32Array(numSamples);
|
|
116
|
+
for (let i = 0; i < numSamples; i++) {
|
|
117
|
+
channelData[i] = rawF32[i * numChannels + ch];
|
|
118
|
+
}
|
|
119
|
+
deinterleaved.push(channelData);
|
|
177
120
|
}
|
|
121
|
+
return {
|
|
122
|
+
channels: deinterleaved,
|
|
123
|
+
sampleRate,
|
|
124
|
+
durationMs: Math.round((numSamples / sampleRate) * 1000),
|
|
125
|
+
};
|
|
178
126
|
}
|
|
179
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@series-inc/stowkit-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"stowkit": "./dist/cli.js"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@series-inc/stowkit-editor": "^0.1.1",
|
|
22
22
|
"draco3d": "^1.5.7",
|
|
23
23
|
"fbx-parser": "^2.1.3",
|
|
24
|
-
"ffmpeg-
|
|
24
|
+
"@strangeape/ffmpeg-audio-wasm": "^0.1.0",
|
|
25
25
|
"sharp": "^0.33.5",
|
|
26
26
|
"three": "^0.182.0",
|
|
27
27
|
"ws": "^8.18.0"
|