@audio/mic 1.1.0
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.
- package/README.md +120 -0
- package/binding.gyp +38 -0
- package/browser.d.ts +19 -0
- package/browser.js +142 -0
- package/index.d.ts +18 -0
- package/index.js +44 -0
- package/native/mic.c +346 -0
- package/native/miniaudio.h +95864 -0
- package/package.json +81 -0
- package/src/backend.js +31 -0
- package/src/backends/miniaudio.js +54 -0
- package/src/backends/null.js +22 -0
- package/src/backends/process.js +68 -0
- package/stream.d.ts +4 -0
- package/stream.js +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @audio/mic
|
|
2
|
+
|
|
3
|
+
Capture audio from microphone in node or browser.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import mic from '@audio/mic'
|
|
9
|
+
|
|
10
|
+
let read = mic({
|
|
11
|
+
sampleRate: 44100,
|
|
12
|
+
channels: 1,
|
|
13
|
+
bitDepth: 16,
|
|
14
|
+
// bufferSize: 50, // ring buffer ms (default 50)
|
|
15
|
+
// backend: 'miniaudio', // force backend
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
read((err, pcmBuffer) => {
|
|
19
|
+
// process chunk
|
|
20
|
+
})
|
|
21
|
+
read(null) // stop capture
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Async iterable
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import mic from '@audio/mic'
|
|
28
|
+
|
|
29
|
+
let read = mic({ sampleRate: 44100, channels: 1 })
|
|
30
|
+
for await (let chunk of read) {
|
|
31
|
+
// process PCM chunk
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Node Readable
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import MicReadable from '@audio/mic/stream'
|
|
39
|
+
|
|
40
|
+
MicReadable({ sampleRate: 44100, channels: 1 }).pipe(dest)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Backends
|
|
44
|
+
|
|
45
|
+
Tried in order; first successful one wins.
|
|
46
|
+
|
|
47
|
+
| Backend | How | Latency | Install |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `miniaudio` | N-API addon wrapping [miniaudio.h](https://github.com/mackron/miniaudio) | Low | Prebuilt via `@audio/mic-*` packages |
|
|
50
|
+
| `process` | Pipes from ffmpeg/sox/arecord | High | System tool must be installed |
|
|
51
|
+
| `null` | Silent, maintains timing contract | — | Built-in (CI/headless fallback) |
|
|
52
|
+
| `mediastream` | getUserMedia + AudioWorklet (browser) | Low | Built-in |
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
### `read = mic(opts?)`
|
|
57
|
+
|
|
58
|
+
Returns a source function. Options:
|
|
59
|
+
|
|
60
|
+
- `sampleRate` — default `44100`
|
|
61
|
+
- `channels` — default `1`
|
|
62
|
+
- `bitDepth` — `8`, `16` (default), `24`, `32`
|
|
63
|
+
- `bufferSize` — ring buffer in ms, default `50`
|
|
64
|
+
- `backend` — force a specific backend
|
|
65
|
+
|
|
66
|
+
### `read(cb)`
|
|
67
|
+
|
|
68
|
+
Read PCM data. Callback fires with each captured chunk: `(err, buffer) => {}`.
|
|
69
|
+
|
|
70
|
+
### `read(null)`
|
|
71
|
+
|
|
72
|
+
Stop capture. Closes the audio device.
|
|
73
|
+
|
|
74
|
+
### `read.close()`
|
|
75
|
+
|
|
76
|
+
Immediately close the audio device.
|
|
77
|
+
|
|
78
|
+
### `read.backend`
|
|
79
|
+
|
|
80
|
+
Name of the active backend (`'miniaudio'`, `'process'`, `'null'`, `'mediastream'`).
|
|
81
|
+
|
|
82
|
+
## Building
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
npm run build # compile native addon locally
|
|
86
|
+
npm test # run tests
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Publishing
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
# JS-only change (no native code changed):
|
|
93
|
+
npm version patch && git push && git push --tags
|
|
94
|
+
npm publish
|
|
95
|
+
|
|
96
|
+
# Native code changed — rebuild platform packages:
|
|
97
|
+
npm version patch && git push && git push --tags
|
|
98
|
+
gh run watch # wait for CI
|
|
99
|
+
rm -rf artifacts
|
|
100
|
+
gh run download --dir artifacts \
|
|
101
|
+
-n mic-darwin-arm64 -n mic-darwin-x64 \
|
|
102
|
+
-n mic-linux-x64 -n mic-linux-arm64 -n mic-win32-x64
|
|
103
|
+
|
|
104
|
+
# (fallback) If darwin-x64 CI is unavailable, cross-compile locally:
|
|
105
|
+
npx node-gyp@latest rebuild --arch=x64
|
|
106
|
+
mkdir -p artifacts/mic-darwin-x64
|
|
107
|
+
cp build/Release/mic.node artifacts/mic-darwin-x64/
|
|
108
|
+
|
|
109
|
+
for pkg in packages/mic-*/; do
|
|
110
|
+
cp artifacts/$(basename $pkg)/mic.node $pkg/
|
|
111
|
+
(cd $pkg && npm publish)
|
|
112
|
+
done
|
|
113
|
+
npm publish
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
119
|
+
|
|
120
|
+
<a href="https://github.com/krishnized/license/">ॐ</a>
|
package/binding.gyp
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"targets": [{
|
|
3
|
+
"target_name": "mic",
|
|
4
|
+
"sources": ["native/mic.c"],
|
|
5
|
+
"include_dirs": ["native"],
|
|
6
|
+
"cflags": ["-std=c99", "-O2"],
|
|
7
|
+
"xcode_settings": {
|
|
8
|
+
"OTHER_CFLAGS": ["-std=c99", "-O2"],
|
|
9
|
+
"MACOSX_DEPLOYMENT_TARGET": "10.13"
|
|
10
|
+
},
|
|
11
|
+
"msvs_settings": {
|
|
12
|
+
"VCCLCompilerTool": {
|
|
13
|
+
"AdditionalOptions": ["/O2"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"conditions": [
|
|
17
|
+
["OS=='mac'", {
|
|
18
|
+
"libraries": [
|
|
19
|
+
"-framework CoreAudio",
|
|
20
|
+
"-framework AudioToolbox",
|
|
21
|
+
"-framework CoreFoundation"
|
|
22
|
+
]
|
|
23
|
+
}],
|
|
24
|
+
["OS=='linux'", {
|
|
25
|
+
"libraries": [
|
|
26
|
+
"-lpthread",
|
|
27
|
+
"-lm",
|
|
28
|
+
"-ldl"
|
|
29
|
+
]
|
|
30
|
+
}],
|
|
31
|
+
["OS=='win'", {
|
|
32
|
+
"libraries": [
|
|
33
|
+
"ole32.lib"
|
|
34
|
+
]
|
|
35
|
+
}]
|
|
36
|
+
]
|
|
37
|
+
}]
|
|
38
|
+
}
|
package/browser.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface BrowserMicOptions {
|
|
2
|
+
sampleRate?: number
|
|
3
|
+
channels?: number
|
|
4
|
+
bitDepth?: 8 | 16 | 32
|
|
5
|
+
context?: AudioContext
|
|
6
|
+
echoCancellation?: boolean
|
|
7
|
+
noiseSuppression?: boolean
|
|
8
|
+
autoGainControl?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReadFn {
|
|
12
|
+
(cb: (err: Error | null, chunk?: Uint8Array | null) => void): void
|
|
13
|
+
(cb: null): void
|
|
14
|
+
end(): void
|
|
15
|
+
close(): void
|
|
16
|
+
backend: 'mediastream'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function mic(opts?: BrowserMicOptions): Promise<ReadFn>
|
package/browser.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module audio-mic/browser
|
|
3
|
+
*
|
|
4
|
+
* Browser audio capture via getUserMedia + AudioWorklet.
|
|
5
|
+
* Falls back to ScriptProcessorNode if AudioWorklet unavailable.
|
|
6
|
+
*
|
|
7
|
+
* Note: unlike node, browser mic() is async because getUserMedia requires permission.
|
|
8
|
+
*/
|
|
9
|
+
export default async function mic(opts = {}) {
|
|
10
|
+
const channels = opts.channels || 1
|
|
11
|
+
const sampleRate = opts.sampleRate || 44100
|
|
12
|
+
const bitDepth = opts.bitDepth || 16
|
|
13
|
+
|
|
14
|
+
const constraints = {
|
|
15
|
+
audio: {
|
|
16
|
+
sampleRate: { ideal: sampleRate },
|
|
17
|
+
channelCount: { ideal: channels },
|
|
18
|
+
echoCancellation: opts.echoCancellation ?? false,
|
|
19
|
+
noiseSuppression: opts.noiseSuppression ?? false,
|
|
20
|
+
autoGainControl: opts.autoGainControl ?? false,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
|
25
|
+
|
|
26
|
+
const ownCtx = !opts.context
|
|
27
|
+
const ctx = opts.context || new AudioContext({ sampleRate })
|
|
28
|
+
const source = ctx.createMediaStreamSource(stream)
|
|
29
|
+
|
|
30
|
+
let closed = false
|
|
31
|
+
let pending = null
|
|
32
|
+
|
|
33
|
+
// try AudioWorklet, fall back to ScriptProcessor
|
|
34
|
+
let node
|
|
35
|
+
if (ctx.audioWorklet) {
|
|
36
|
+
const workletCode = `
|
|
37
|
+
class MicProcessor extends AudioWorkletProcessor {
|
|
38
|
+
process(inputs) {
|
|
39
|
+
const input = inputs[0]
|
|
40
|
+
if (input && input.length > 0) {
|
|
41
|
+
const channels = []
|
|
42
|
+
for (let i = 0; i < input.length; i++) channels.push(input[i].slice())
|
|
43
|
+
this.port.postMessage(channels)
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
registerProcessor('mic-processor', MicProcessor)
|
|
49
|
+
`
|
|
50
|
+
const blob = new Blob([workletCode], { type: 'application/javascript' })
|
|
51
|
+
const url = URL.createObjectURL(blob)
|
|
52
|
+
await ctx.audioWorklet.addModule(url)
|
|
53
|
+
URL.revokeObjectURL(url)
|
|
54
|
+
|
|
55
|
+
node = new AudioWorkletNode(ctx, 'mic-processor', {
|
|
56
|
+
numberOfInputs: 1,
|
|
57
|
+
numberOfOutputs: 0,
|
|
58
|
+
channelCount: channels
|
|
59
|
+
})
|
|
60
|
+
source.connect(node)
|
|
61
|
+
|
|
62
|
+
node.port.onmessage = (e) => {
|
|
63
|
+
if (closed || !pending) return
|
|
64
|
+
const cb = pending
|
|
65
|
+
pending = null
|
|
66
|
+
cb(null, float32ToPCM(e.data, bitDepth))
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// ScriptProcessorNode fallback (deprecated but wider support)
|
|
70
|
+
const bufSize = 2048
|
|
71
|
+
node = ctx.createScriptProcessor(bufSize, channels, 1)
|
|
72
|
+
source.connect(node)
|
|
73
|
+
node.connect(ctx.destination) // required for processing to run
|
|
74
|
+
|
|
75
|
+
node.onaudioprocess = (e) => {
|
|
76
|
+
if (closed || !pending) return
|
|
77
|
+
const cb = pending
|
|
78
|
+
pending = null
|
|
79
|
+
const chans = []
|
|
80
|
+
for (let i = 0; i < channels; i++) chans.push(e.inputBuffer.getChannelData(i).slice())
|
|
81
|
+
cb(null, float32ToPCM(chans, bitDepth))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
read.close = close
|
|
86
|
+
read.end = close
|
|
87
|
+
read.backend = 'mediastream'
|
|
88
|
+
read[Symbol.asyncIterator] = () => ({
|
|
89
|
+
next: () => new Promise((resolve) => {
|
|
90
|
+
if (closed) return resolve({ done: true, value: undefined })
|
|
91
|
+
if (ctx.state === 'suspended') ctx.resume()
|
|
92
|
+
pending = (err, chunk) => {
|
|
93
|
+
if (err || !chunk) return resolve({ done: true, value: undefined })
|
|
94
|
+
resolve({ done: false, value: chunk })
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return read
|
|
100
|
+
|
|
101
|
+
function read(cb) {
|
|
102
|
+
if (cb == null || closed) {
|
|
103
|
+
close()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
// resume suspended context (autoplay policy)
|
|
107
|
+
if (ctx.state === 'suspended') ctx.resume()
|
|
108
|
+
pending = cb
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function close() {
|
|
112
|
+
if (closed) return
|
|
113
|
+
closed = true
|
|
114
|
+
pending = null
|
|
115
|
+
source.disconnect()
|
|
116
|
+
stream.getTracks().forEach(t => t.stop())
|
|
117
|
+
if (ownCtx) ctx.close?.()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function float32ToPCM(channelData, bits) {
|
|
121
|
+
const ch = channelData.length
|
|
122
|
+
const len = channelData[0].length
|
|
123
|
+
const bps = bits / 8
|
|
124
|
+
const buf = new Uint8Array(len * ch * bps)
|
|
125
|
+
const view = new DataView(buf.buffer)
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < len; i++) {
|
|
128
|
+
for (let c = 0; c < ch; c++) {
|
|
129
|
+
const sample = channelData[c][i]
|
|
130
|
+
const offset = (i * ch + c) * bps
|
|
131
|
+
if (bits === 16) {
|
|
132
|
+
view.setInt16(offset, Math.max(-32768, Math.min(32767, Math.round(sample * 32767))), true)
|
|
133
|
+
} else if (bits === 32) {
|
|
134
|
+
view.setFloat32(offset, sample, true)
|
|
135
|
+
} else if (bits === 8) {
|
|
136
|
+
buf[offset] = Math.max(0, Math.min(255, Math.round((sample + 1) * 127.5)))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return buf
|
|
141
|
+
}
|
|
142
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface MicOptions {
|
|
2
|
+
sampleRate?: number
|
|
3
|
+
channels?: number
|
|
4
|
+
bitDepth?: 8 | 16 | 24 | 32
|
|
5
|
+
bufferSize?: number
|
|
6
|
+
backend?: 'miniaudio' | 'process' | 'null'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ReadFn {
|
|
10
|
+
(cb: (err: Error | null, chunk?: Buffer | Uint8Array | null) => void): void
|
|
11
|
+
(cb: null): void
|
|
12
|
+
end(): void
|
|
13
|
+
close(): void
|
|
14
|
+
backend: string
|
|
15
|
+
[Symbol.asyncIterator](): AsyncIterator<Buffer | Uint8Array>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function mic(opts?: MicOptions): ReadFn
|
package/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module audio-mic
|
|
3
|
+
*
|
|
4
|
+
* Capture audio data from microphone.
|
|
5
|
+
* let read = mic({ sampleRate: 44100 })
|
|
6
|
+
* read((err, chunk) => {})
|
|
7
|
+
* read(null) // stop
|
|
8
|
+
*/
|
|
9
|
+
import { open } from './src/backend.js'
|
|
10
|
+
|
|
11
|
+
const defaults = {
|
|
12
|
+
sampleRate: 44100,
|
|
13
|
+
channels: 1,
|
|
14
|
+
bitDepth: 16,
|
|
15
|
+
bufferSize: 50
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function mic(opts) {
|
|
19
|
+
const config = { ...defaults, ...opts }
|
|
20
|
+
const { name, device } = open(config, config.backend)
|
|
21
|
+
|
|
22
|
+
read.close = () => { device.close() }
|
|
23
|
+
read.end = () => { device.close() }
|
|
24
|
+
read.backend = name
|
|
25
|
+
read[Symbol.asyncIterator] = () => ({
|
|
26
|
+
next: () => new Promise((resolve, reject) => {
|
|
27
|
+
device.read((err, chunk) => {
|
|
28
|
+
if (err) return reject(err)
|
|
29
|
+
if (!chunk) return resolve({ done: true, value: undefined })
|
|
30
|
+
resolve({ done: false, value: chunk })
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return read
|
|
36
|
+
|
|
37
|
+
function read(cb) {
|
|
38
|
+
if (cb == null) {
|
|
39
|
+
device.close()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
device.read(cb)
|
|
43
|
+
}
|
|
44
|
+
}
|