@series-inc/stowkit-cli 0.1.8 → 0.1.10

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
- const execFileAsync = promisify(execFile);
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,103 @@ function createWavBuffer(channels, sampleRate) {
44
39
  }
45
40
  return new Uint8Array(buffer);
46
41
  }
47
- // ─── Resolve ffmpeg binary ──────────────────────────────────────────────────
48
- async function getFfmpegPath() {
49
- try {
50
- // Try ffmpeg-static first
51
- const mod = await import('ffmpeg-static');
52
- const p = (mod.default ?? mod);
53
- if (p)
54
- return p;
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
- catch {
57
- // Fallback to PATH
58
+ return { sampleRate, numChannels };
59
+ }
60
+ // ─── WAV data chunk extraction ──────────────────────────────────────────────
61
+ function extractWavData(wav) {
62
+ const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength);
63
+ let offset = 12; // skip RIFF header (4) + size (4) + WAVE (4)
64
+ while (offset + 8 <= wav.byteLength) {
65
+ const chunkId = String.fromCharCode(wav[offset], wav[offset + 1], wav[offset + 2], wav[offset + 3]);
66
+ const chunkSize = view.getUint32(offset + 4, true);
67
+ if (chunkId === 'data') {
68
+ return wav.slice(offset + 8, offset + 8 + chunkSize);
69
+ }
70
+ offset += 8 + chunkSize;
58
71
  }
59
- return 'ffmpeg';
72
+ throw new Error('WAV data chunk not found');
60
73
  }
61
74
  // ─── NodeAacEncoder ─────────────────────────────────────────────────────────
62
75
  export class NodeAacEncoder {
63
- ffmpegPath = null;
64
- async initialize() {
65
- this.ffmpegPath = await getFfmpegPath();
66
- }
76
+ async initialize() { }
67
77
  async encode(channels, sampleRate, quality) {
68
- if (!this.ffmpegPath)
69
- await this.initialize();
70
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stowkit-'));
71
- const inFile = path.join(tmpDir, 'in.wav');
72
- const outFile = path.join(tmpDir, 'out.m4a');
73
- try {
74
- const wavBytes = createWavBuffer(channels, sampleRate);
75
- await fs.writeFile(inFile, wavBytes);
76
- const vbr = QUALITY_TO_VBR[quality];
77
- await execFileAsync(this.ffmpegPath, [
78
- '-y',
79
- '-i', inFile,
80
- '-c:a', 'aac',
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(() => { });
78
+ const ffmpeg = await createFFmpegAudio();
79
+ const wavBytes = createWavBuffer(channels, sampleRate);
80
+ ffmpeg.writeFile('/input.wav', wavBytes);
81
+ const vbr = QUALITY_TO_VBR[quality];
82
+ const code = await ffmpeg.exec([
83
+ '-y',
84
+ '-i', '/input.wav',
85
+ '-c:a', 'aac',
86
+ '-q:a', vbr,
87
+ '/output.m4a',
88
+ ]);
89
+ if (code !== 0) {
90
+ throw new Error(`FFmpeg AAC encode failed (code ${code}): ${ffmpeg.getLastStderr()}`);
89
91
  }
92
+ return ffmpeg.readFile('/output.m4a');
90
93
  }
91
94
  }
92
95
  // ─── NodeAudioDecoder ───────────────────────────────────────────────────────
93
96
  export class NodeAudioDecoder {
94
- ffmpegPath = null;
95
- async initialize() {
96
- this.ffmpegPath = await getFfmpegPath();
97
- }
97
+ async initialize() { }
98
98
  async decodeToPcm(audioData, _fileName) {
99
- if (!this.ffmpegPath)
100
- await this.initialize();
101
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stowkit-'));
102
- const inFile = path.join(tmpDir, 'input');
103
- const outFile = path.join(tmpDir, 'output.raw');
104
- try {
105
- await fs.writeFile(inFile, audioData);
106
- // First, get audio info
107
- const { stdout } = await execFileAsync(this.ffmpegPath, [
108
- '-i', inFile,
109
- '-hide_banner',
110
- ], { encoding: 'utf-8' }).catch(e => {
111
- // ffmpeg exits non-zero when only probing, but prints info to stderr
112
- return { stdout: '', stderr: e.stderr ?? '' };
113
- });
114
- const infoText = stdout || '';
115
- // Parse sample rate and channels from ffmpeg output
116
- const sampleRateMatch = infoText.match(/(\d+) Hz/);
117
- const channelsMatch = infoText.match(/(\d+) channels/) || infoText.match(/(mono|stereo)/i);
118
- // Use ffprobe-style approach: decode to raw PCM f32le
119
- await execFileAsync(this.ffmpegPath, [
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
- };
99
+ // First pass: probe audio info via a null mux
100
+ const probeFFmpeg = await createFFmpegAudio();
101
+ probeFFmpeg.writeFile('/input', audioData);
102
+ await probeFFmpeg.exec([
103
+ '-i', '/input',
104
+ '-f', 'null',
105
+ '-',
106
+ ]);
107
+ const { sampleRate, numChannels } = parseAudioInfo(probeFFmpeg.getLastStderr());
108
+ // Second pass: decode to f32le WAV (ffmpeg 4.4 needs WAV muxer for pcm_f32le output)
109
+ const ffmpeg = await createFFmpegAudio();
110
+ ffmpeg.writeFile('/input', audioData);
111
+ const code = await ffmpeg.exec([
112
+ '-y',
113
+ '-i', '/input',
114
+ '-f', 'wav',
115
+ '-acodec', 'pcm_f32le',
116
+ '/output.wav',
117
+ ]);
118
+ if (code !== 0) {
119
+ throw new Error(`FFmpeg decode failed (code ${code}): ${ffmpeg.getLastStderr()}`);
174
120
  }
175
- finally {
176
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
121
+ const wavBuf = ffmpeg.readFile('/output.wav');
122
+ // Find the "data" chunk WAV headers can be longer than 44 bytes
123
+ const rawBuf = extractWavData(wavBuf);
124
+ const rawF32 = new Float32Array(rawBuf.buffer, rawBuf.byteOffset, rawBuf.byteLength / 4);
125
+ const numSamples = Math.floor(rawF32.length / numChannels);
126
+ // Deinterleave
127
+ const deinterleaved = [];
128
+ for (let ch = 0; ch < numChannels; ch++) {
129
+ const channelData = new Float32Array(numSamples);
130
+ for (let i = 0; i < numSamples; i++) {
131
+ channelData[i] = rawF32[i * numChannels + ch];
132
+ }
133
+ deinterleaved.push(channelData);
177
134
  }
135
+ return {
136
+ channels: deinterleaved,
137
+ sampleRate,
138
+ durationMs: Math.round((numSamples / sampleRate) * 1000),
139
+ };
178
140
  }
179
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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-static": "^5.2.0",
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"