Skip to content

Commit 92d4b1d

Browse files
committed
Add streaming api
1 parent ab6e895 commit 92d4b1d

7 files changed

Lines changed: 99 additions & 80 deletions

File tree

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,20 @@ const c = await enc(null); // end of stream — flush + free
5454
// explicit control: enc.flush(), enc.free()
5555
```
5656

57-
### TransformStream
57+
### Streaming
58+
59+
Pass an async iterable as data — returns an async generator:
5860

5961
```js
60-
import encodeStream from 'encode-audio/stream';
62+
import encode from 'encode-audio'
6163

62-
audioSource
63-
.pipeThrough(encodeStream('mp3', { sampleRate: 44100, bitrate: 128 }))
64-
.pipeTo(destination);
64+
for await (let buf of encode.mp3(audioSource, { sampleRate: 44100, bitrate: 128 })) {
65+
// buf is Uint8Array
66+
}
6567
```
6668

69+
Works with any async iterable source.
70+
6771
### Options
6872

6973
| Option | Description | Applies to |

audio-encode.d.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ export interface StreamEncoder {
2323
(channelData: AudioInput): Promise<Uint8Array>;
2424
/** Flush remaining data, finalize, and free resources. */
2525
(): Promise<Uint8Array>;
26-
/** @deprecated Use enc() instead. */
27-
encode(channelData?: AudioInput): Promise<Uint8Array>;
2826
/** Flush without freeing. */
2927
flush(): Promise<Uint8Array>;
3028
/** Free resources without flushing. */
@@ -34,13 +32,20 @@ export interface StreamEncoder {
3432
export interface FormatEncoder {
3533
/** Whole-file encode. */
3634
(channelData: AudioInput, opts: EncodeOptions): Promise<Uint8Array>;
35+
/** Chunked encode from async iterable. */
36+
(source: AsyncIterable<AudioInput>, opts: EncodeOptions): AsyncGenerator<Uint8Array>;
3737
/** Create streaming encoder. */
3838
(opts: EncodeOptions): Promise<StreamEncoder>;
39-
/** @deprecated Use encode.fmt(opts) instead. */
40-
stream(opts: EncodeOptions): Promise<StreamEncoder>;
4139
}
4240

4341
declare const encode: {
42+
/** Whole-file encode. */
43+
(format: string, channelData: AudioInput, opts: EncodeOptions): Promise<Uint8Array>;
44+
/** Chunked encode from async iterable. */
45+
(format: string, source: AsyncIterable<AudioInput>, opts: EncodeOptions): AsyncGenerator<Uint8Array>;
46+
/** Create streaming encoder. */
47+
(format: string, opts: EncodeOptions): Promise<StreamEncoder>;
48+
4449
wav: FormatEncoder;
4550
aiff: FormatEncoder;
4651
mp3: FormatEncoder;
@@ -52,6 +57,13 @@ declare const encode: {
5257

5358
export default encode;
5459

60+
/** Chunked encode from async iterable. */
61+
export function encodeChunked(
62+
source: AsyncIterable<AudioInput>,
63+
format: string,
64+
opts: EncodeOptions
65+
): AsyncGenerator<Uint8Array>;
66+
5567
/** Wrap codec callbacks into a StreamEncoder with lifecycle management. */
5668
export function streamEncoder(
5769
onEncode: (channels: Float32Array[]) => Uint8Array | Promise<Uint8Array>,

audio-encode.js

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,58 @@
11
/**
2-
* Audio encoder: whole-file and streaming
2+
* Audio encoder: whole-file and chunked
33
* @module encode-audio
44
*
55
* let buf = await encode.wav(channelData, { sampleRate: 44100 })
66
*
7+
* for await (let bytes of encode.mp3(source, { sampleRate: 44100 })) { ... }
8+
*
79
* let enc = await encode.mp3({ sampleRate: 44100, bitrate: 128 })
810
* let chunk = await enc(channelData)
911
* let final = await enc() // flush + free
1012
*/
1113

1214
const EMPTY = new Uint8Array(0)
1315

14-
const encode = {}
16+
/**
17+
* Encode audio — delegates to format-specific encoder.
18+
* encode('wav', channelData, { sampleRate }) → Promise<Uint8Array>
19+
* encode('wav', source(), { sampleRate }) → AsyncGenerator<Uint8Array>
20+
* encode('wav', { sampleRate }) → Promise<StreamEncoder>
21+
*/
22+
function encode(format, data, opts) {
23+
if (!encode[format]) throw Error('Unknown format: ' + format)
24+
return encode[format](data, opts)
25+
}
1526
export default encode
1627

28+
function isAudioData(d) {
29+
return d instanceof Float32Array || Array.isArray(d) || (d?.getChannelData && d?.numberOfChannels)
30+
}
31+
32+
/**
33+
* Encode a stream of PCM chunks to the given format.
34+
* @param {AsyncIterable<Float32Array[]|Float32Array>} source
35+
* @param {string} format
36+
* @param {object} opts - encoder options (sampleRate required)
37+
* @returns {AsyncGenerator<Uint8Array>}
38+
*/
39+
async function* encodeChunked(source, format, opts) {
40+
let enc = await encode[format](opts)
41+
try {
42+
for await (let chunk of source) {
43+
let buf = await enc(chunk)
44+
if (buf.length) yield buf
45+
}
46+
let final = await enc()
47+
if (final.length) yield final
48+
} catch (e) { enc.free(); throw e }
49+
}
50+
export { encodeChunked }
51+
1752
// --- format registration ---
1853

1954
function reg(name, load) {
20-
encode[name] = fmt(async (opts) => {
55+
encode[name] = fmt(name, async (opts) => {
2156
let init = (await load()).default
2257
let codec = await init(opts)
2358
return streamEncoder(ch => codec.encode(ch), () => codec.flush(), () => codec.free())
@@ -36,26 +71,31 @@ reg('opus', () => import('@audio/encode-opus'))
3671
* 1 arg (opts) → streaming encoder function
3772
* 2 args (data, opts) → whole-file encode
3873
*/
39-
function fmt(init) {
40-
let fn = async (data, opts) => {
74+
function fmt(name, init) {
75+
let fn = (data, opts) => {
4176
// 1 arg = streaming: encode.mp3({ sampleRate })
4277
if (!opts) return init(data)
78+
// 2 args, async iterable = chunked: encode.mp3(source(), { sampleRate })
79+
if (data && (typeof data[Symbol.asyncIterator] === 'function' || typeof data[Symbol.iterator] === 'function' && !isAudioData(data)))
80+
return encodeChunked(data, name, opts)
4381
// 2 args = whole-file: encode.mp3(channelData, { sampleRate })
44-
if (!opts.sampleRate) throw Error('sampleRate is required')
45-
let ch = channels(data)
46-
if (!ch.length || !ch[0].length) return EMPTY
47-
let enc = await init({ channels: ch.length, ...opts })
48-
try {
49-
let result = await enc(ch)
50-
let flushed = await enc()
51-
return merge(result, flushed)
52-
} catch (e) { enc.free(); throw e }
82+
return wholeFile(data, opts, init)
5383
}
54-
// TODO: remove .stream in next major
55-
fn.stream = init
5684
return fn
5785
}
5886

87+
async function wholeFile(data, opts, init) {
88+
if (!opts.sampleRate) throw Error('sampleRate is required')
89+
let ch = channels(data)
90+
if (!ch.length || !ch[0].length) return EMPTY
91+
let enc = await init({ channels: ch.length, ...opts })
92+
try {
93+
let result = await enc(ch)
94+
let flushed = await enc()
95+
return merge(result, flushed)
96+
} catch (e) { enc.free(); throw e }
97+
}
98+
5999
// normalize input to Float32Array[]
60100
function channels(data) {
61101
if (!data) return []
@@ -98,8 +138,6 @@ export function streamEncoder(onEncode, onFlush, onFree) {
98138
return result
99139
} catch (e) { onFree?.(); throw e }
100140
}
101-
// TODO: remove .encode in next major
102-
fn.encode = fn
103141
fn.flush = async () => {
104142
if (done) return EMPTY
105143
return onFlush ? norm(await onFlush()) : EMPTY

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

stream.d.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1 @@
1-
type AudioInput = Float32Array[] | Float32Array | { numberOfChannels: number; getChannelData(i: number): Float32Array };
2-
3-
/**
4-
* Create a TransformStream that encodes audio chunks to the given format.
5-
* @param format - 'wav', 'mp3', 'ogg', 'opus', 'flac', 'aiff'
6-
* @param opts - encoder options (sampleRate required)
7-
*/
8-
export default function encodeStream(
9-
format: string,
10-
opts: import('./audio-encode.js').EncodeOptions
11-
): TransformStream<AudioInput, Uint8Array>;
1+
export { encodeChunked as default, encodeChunked } from './audio-encode.js';

stream.js

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,7 @@
11
/**
2-
* TransformStream for audio encoding
2+
* Chunked encoder — re-exports encodeChunked from main module.
33
* @module encode-audio/stream
44
*
5-
* audioSource.pipeThrough(encodeStream('mp3', { sampleRate: 44100, bitrate: 128 }))
5+
* for await (let bytes of encode.mp3(pcmSource, { sampleRate: 44100 })) { ... }
66
*/
7-
8-
import encode from './audio-encode.js'
9-
10-
/**
11-
* @param {string} format - 'wav', 'mp3', 'ogg', 'opus', 'flac', 'aiff'
12-
* @param {object} opts - encoder options (sampleRate required)
13-
* @returns {TransformStream<Float32Array[]|Float32Array, Uint8Array>}
14-
*/
15-
export default function encodeStream(format, opts) {
16-
if (!encode[format]) throw Error('Unknown format: ' + format)
17-
let enc
18-
return new TransformStream({
19-
async start() { enc = await encode[format](opts) },
20-
async transform(chunk, ctrl) {
21-
let buf = await enc(chunk)
22-
if (buf.length) ctrl.enqueue(buf)
23-
},
24-
async flush(ctrl) {
25-
let buf = await enc()
26-
if (buf.length) ctrl.enqueue(buf)
27-
}
28-
})
29-
}
7+
export { encodeChunked as default, encodeChunked } from './audio-encode.js'

test.js

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import t, { is, ok, almost } from 'tst'
22
import encode from './audio-encode.js'
3-
import encodeStream from './stream.js'
43
import decode from 'audio-decode'
54
import AudioBuffer from 'audio-buffer'
65

@@ -89,26 +88,24 @@ t('streaming (callable)', async () => {
8988
ok(c1.length > 0 || c2.length > 0 || final.length > 0)
9089
})
9190

92-
t('streaming (deprecated .stream/.encode)', async () => {
93-
let enc = await encode.wav.stream({ sampleRate: 44100 })
94-
let c1 = await enc.encode(sine(44100, 440, 0.5))
95-
let c2 = await enc.encode(sine(44100, 440, 0.5))
96-
let final = await enc.encode()
97-
ok(c1.length > 0 || c2.length > 0 || final.length > 0)
98-
})
99-
100-
t('TransformStream', async () => {
91+
t('encode.wav(source, opts) chunked', async () => {
10192
let chunks = [sine(44100, 440, 0.5), sine(44100, 440, 0.5)]
102-
let source = new ReadableStream({
103-
pull(ctrl) { chunks.length ? ctrl.enqueue(chunks.shift()) : ctrl.close() }
104-
})
93+
async function* source() { for (let c of chunks) yield c }
10594
let out = []
106-
let dest = new WritableStream({ write(chunk) { out.push(chunk) } })
107-
await source.pipeThrough(encodeStream('wav', { sampleRate: 44100 })).pipeTo(dest)
95+
for await (let buf of encode.wav(source(), { sampleRate: 44100 })) out.push(buf)
10896
ok(out.length > 0, 'produced chunks')
10997
ok(out.every(c => c instanceof Uint8Array), 'all Uint8Array')
11098
})
11199

100+
t('encode(format, data) whole-file', async () => {
101+
let { channelData, sampleRate } = await getLena()
102+
let buf = await encode('wav', channelData, { sampleRate })
103+
ok(buf.length > 44, 'has data')
104+
let dec = await decode(buf)
105+
is(dec.sampleRate, sampleRate)
106+
almost(rms(dec.channelData[0]), rms(channelData[0]), 0.001, 'rms matches')
107+
})
108+
112109
t('AudioBuffer input', async () => {
113110
let ab = new AudioBuffer({ sampleRate: 44100, length: 44100 })
114111
let ch = ab.getChannelData(0)

0 commit comments

Comments
 (0)