@qwen-code/audio-capture 0.19.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/binding.gyp +28 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +7 -0
- package/dist/platform.js +18 -0
- package/dist/platform.js.map +1 -0
- package/install.js +29 -0
- package/native/audio_capture.cc +421 -0
- package/native/mac_permission.mm +31 -0
- package/native/miniaudio.h +93468 -0
- package/package.json +45 -0
- package/prebuilds/darwin-arm64/@qwen-code+audio-capture.node +0 -0
- package/prebuilds/darwin-x64/@qwen-code+audio-capture.node +0 -0
- package/prebuilds/linux-arm64/@qwen-code+audio-capture.node +0 -0
- package/prebuilds/linux-x64/@qwen-code+audio-capture.node +0 -0
- package/prebuilds/win32-x64/@qwen-code+audio-capture.node +0 -0
package/binding.gyp
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"targets": [
|
|
3
|
+
{
|
|
4
|
+
"target_name": "audio_capture",
|
|
5
|
+
"sources": ["native/audio_capture.cc"],
|
|
6
|
+
"include_dirs": ["native"],
|
|
7
|
+
"conditions": [
|
|
8
|
+
["OS=='mac'", {
|
|
9
|
+
"sources": ["native/mac_permission.mm"],
|
|
10
|
+
"libraries": [
|
|
11
|
+
"-framework CoreAudio",
|
|
12
|
+
"-framework AudioToolbox",
|
|
13
|
+
"-framework AudioUnit",
|
|
14
|
+
"-framework CoreFoundation",
|
|
15
|
+
"-framework AVFoundation",
|
|
16
|
+
"-framework Foundation"
|
|
17
|
+
]
|
|
18
|
+
}],
|
|
19
|
+
["OS=='linux'", {
|
|
20
|
+
"libraries": ["-ldl", "-lpthread", "-lm"]
|
|
21
|
+
}],
|
|
22
|
+
["OS=='win'", {
|
|
23
|
+
"libraries": ["winmm.lib", "ole32.lib", "uuid.lib", "ksuser.lib"]
|
|
24
|
+
}]
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
export type MicrophoneAuthorizationStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
|
7
|
+
export interface AudioCaptureOptions {
|
|
8
|
+
sampleRate: number;
|
|
9
|
+
channels: number;
|
|
10
|
+
/** Flag sustained silence so the caller can auto-stop (tap mode). */
|
|
11
|
+
silenceDetection?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface NativeAudioCaptureBackend {
|
|
14
|
+
startRecording: (options: AudioCaptureOptions) => void;
|
|
15
|
+
stopRecording: () => Uint8Array;
|
|
16
|
+
isRecording: () => boolean;
|
|
17
|
+
/** True once sustained silence was detected. Absent on older addons. */
|
|
18
|
+
silenceDetected?: () => boolean;
|
|
19
|
+
/** Return & clear PCM captured since the last call (for streaming uploads). */
|
|
20
|
+
drainAudio?: () => Uint8Array;
|
|
21
|
+
/** Recent input level 0..1 (for waveform display). */
|
|
22
|
+
audioLevel?: () => number;
|
|
23
|
+
microphoneAuthorizationStatus: () => MicrophoneAuthorizationStatus;
|
|
24
|
+
}
|
|
25
|
+
interface NativeBinding {
|
|
26
|
+
startRecording: (options?: Partial<AudioCaptureOptions>) => void;
|
|
27
|
+
stopRecording: () => Uint8Array;
|
|
28
|
+
isRecording: () => boolean;
|
|
29
|
+
silenceDetected?: () => boolean;
|
|
30
|
+
drainAudio?: () => Uint8Array;
|
|
31
|
+
audioLevel?: () => number;
|
|
32
|
+
microphoneAuthorizationStatus?: () => MicrophoneAuthorizationStatus;
|
|
33
|
+
}
|
|
34
|
+
export declare function createNativeAudioCaptureBackend(binding?: NativeBinding): NativeAudioCaptureBackend;
|
|
35
|
+
export { getPlatformBackendName } from './platform.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { getPlatformBackendName } from './platform.js';
|
|
10
|
+
const nativeRequire = createRequire(import.meta.url);
|
|
11
|
+
// dist/index.js → package root, which holds prebuilds/ and build/.
|
|
12
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
function loadBinding() {
|
|
14
|
+
try {
|
|
15
|
+
// Throws on unsupported platforms before touching the native layer.
|
|
16
|
+
getPlatformBackendName();
|
|
17
|
+
// node-gyp-build picks the matching prebuild from prebuilds/<platform>-<arch>,
|
|
18
|
+
// falling back to a local build/Release compile — no compiler needed when a
|
|
19
|
+
// prebuilt binary ships for the host.
|
|
20
|
+
const loadPrebuild = nativeRequire('node-gyp-build');
|
|
21
|
+
return loadPrebuild(packageRoot);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new Error('Native audio capture addon could not be loaded. Reinstall ' +
|
|
25
|
+
'@qwen-code/audio-capture, or run "npm run build" in packages/audio-capture. ' +
|
|
26
|
+
`(${error instanceof Error ? error.message : String(error)})`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function createNativeAudioCaptureBackend(binding = loadBinding()) {
|
|
30
|
+
const silenceDetected = binding.silenceDetected;
|
|
31
|
+
const drainAudio = binding.drainAudio;
|
|
32
|
+
const audioLevel = binding.audioLevel;
|
|
33
|
+
return {
|
|
34
|
+
startRecording: (options) => {
|
|
35
|
+
binding.startRecording(options);
|
|
36
|
+
},
|
|
37
|
+
stopRecording: () => binding.stopRecording(),
|
|
38
|
+
isRecording: () => binding.isRecording(),
|
|
39
|
+
...(drainAudio ? { drainAudio: () => drainAudio.call(binding) } : {}),
|
|
40
|
+
...(audioLevel ? { audioLevel: () => audioLevel.call(binding) } : {}),
|
|
41
|
+
...(silenceDetected
|
|
42
|
+
? { silenceDetected: () => silenceDetected.call(binding) }
|
|
43
|
+
: {}),
|
|
44
|
+
microphoneAuthorizationStatus: () => binding.microphoneAuthorizationStatus?.() ?? 'unknown',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export { getPlatformBackendName } from './platform.js';
|
|
48
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AAsCvD,MAAM,aAAa,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACrD,mEAAmE;AACnE,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAErE,SAAS,WAAW;IAClB,IAAI,CAAC;QACH,oEAAoE;QACpE,sBAAsB,EAAE,CAAC;QACzB,+EAA+E;QAC/E,4EAA4E;QAC5E,sCAAsC;QACtC,MAAM,YAAY,GAAG,aAAa,CAAC,gBAAgB,CAEjC,CAAC;QACnB,OAAO,YAAY,CAAC,WAAW,CAAC,CAAC;IACnC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,4DAA4D;YAC1D,8EAA8E;YAC9E,IAAI,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAChE,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,+BAA+B,CAC7C,UAAyB,WAAW,EAAE;IAEtC,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAChD,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IACtC,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IACtC,OAAO;QACL,cAAc,EAAE,CAAC,OAAO,EAAE,EAAE;YAC1B,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;QAClC,CAAC;QACD,aAAa,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE;QAC5C,WAAW,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE;QACxC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACrE,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACrE,GAAG,CAAC,eAAe;YACjB,CAAC,CAAC,EAAE,eAAe,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE;YAC1D,CAAC,CAAC,EAAE,CAAC;QACP,6BAA6B,EAAE,GAAG,EAAE,CAClC,OAAO,CAAC,6BAA6B,EAAE,EAAE,IAAI,SAAS;KACzD,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
export function getPlatformBackendName(platform = process.platform) {
|
|
7
|
+
switch (platform) {
|
|
8
|
+
case 'darwin':
|
|
9
|
+
return 'coreaudio';
|
|
10
|
+
case 'linux':
|
|
11
|
+
return 'alsa-pulse';
|
|
12
|
+
case 'win32':
|
|
13
|
+
return 'wasapi';
|
|
14
|
+
default:
|
|
15
|
+
throw new Error(`Native audio capture is not available for ${platform}.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=platform.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform.js","sourceRoot":"","sources":["../src/platform.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,UAAU,sBAAsB,CACpC,WAA4B,OAAO,CAAC,QAAQ;IAE5C,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,WAAW,CAAC;QACrB,KAAK,OAAO;YACV,OAAO,YAAY,CAAC;QACtB,KAAK,OAAO;YACV,OAAO,QAAQ,CAAC;QAClB;YACE,MAAM,IAAI,KAAK,CAAC,6CAA6C,QAAQ,GAAG,CAAC,CAAC;IAC9E,CAAC;AACH,CAAC"}
|
package/install.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Build (or locate a prebuild for) the native microphone addon. A failed or
|
|
8
|
+
// impossible build is intentionally NON-FATAL: voice input falls back to the
|
|
9
|
+
// SoX/arecord recorder, so installing the CLI must never break for this optional
|
|
10
|
+
// capability — not on a platform without a prebuild or a C/C++ toolchain, and
|
|
11
|
+
// not for the many users who never use voice. This keeps a voice-only compile
|
|
12
|
+
// failure from turning every `npm ci` (including unrelated CI jobs) red.
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import process from 'node:process';
|
|
15
|
+
|
|
16
|
+
const result = spawnSync('node-gyp-build', [], {
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
shell: true,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (result.status !== 0) {
|
|
22
|
+
process.stderr.write(
|
|
23
|
+
'[audio-capture] native microphone backend unavailable; ' +
|
|
24
|
+
'voice input will fall back to SoX/arecord.\n',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Always exit 0 so a missing native backend never fails the install.
|
|
29
|
+
process.exit(0);
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
#include <node_api.h>
|
|
8
|
+
|
|
9
|
+
#include <algorithm>
|
|
10
|
+
#include <atomic>
|
|
11
|
+
#include <cmath>
|
|
12
|
+
#include <cstdint>
|
|
13
|
+
#include <cstring>
|
|
14
|
+
#include <mutex>
|
|
15
|
+
#include <stdexcept>
|
|
16
|
+
#include <string>
|
|
17
|
+
#include <vector>
|
|
18
|
+
|
|
19
|
+
#define MINIAUDIO_IMPLEMENTATION
|
|
20
|
+
#include "miniaudio.h"
|
|
21
|
+
|
|
22
|
+
#if defined(__APPLE__)
|
|
23
|
+
// Implemented in mac_permission.mm (AVFoundation). 0=notDetermined, 1=restricted,
|
|
24
|
+
// 2=denied, 3=authorized.
|
|
25
|
+
extern "C" int qwen_mic_authorization_status(void);
|
|
26
|
+
#endif
|
|
27
|
+
|
|
28
|
+
namespace {
|
|
29
|
+
|
|
30
|
+
// Silence auto-stop: stop after this much trailing silence below the amplitude
|
|
31
|
+
// threshold (fraction of int16 full-scale). Mirrors the SoX `silence` effect
|
|
32
|
+
// (3% / 2.0s). Only armed after speech is first detected, so leading silence
|
|
33
|
+
// before the user speaks does not trigger a stop.
|
|
34
|
+
constexpr double kSilenceThreshold = 0.03 * 32768.0;
|
|
35
|
+
constexpr double kSilenceDurationSecs = 2.0;
|
|
36
|
+
// Reserve room for the 44-byte WAV header so ToWav output stays under 10 MiB.
|
|
37
|
+
constexpr size_t kMaxPcmBytes = 10 * 1024 * 1024 - 44;
|
|
38
|
+
constexpr size_t kMaxPcmSamples = kMaxPcmBytes / sizeof(int16_t);
|
|
39
|
+
|
|
40
|
+
struct RecorderState {
|
|
41
|
+
ma_device device;
|
|
42
|
+
std::atomic<bool> deviceInitialized{false};
|
|
43
|
+
std::atomic<bool> recording{false};
|
|
44
|
+
uint32_t sampleRate = 16000;
|
|
45
|
+
uint32_t channels = 1;
|
|
46
|
+
std::vector<int16_t> pcm;
|
|
47
|
+
size_t pcmSize = 0;
|
|
48
|
+
std::mutex mutex;
|
|
49
|
+
std::atomic<bool> silenceDetectionEnabled{false};
|
|
50
|
+
std::atomic<bool> speechStarted{false};
|
|
51
|
+
std::atomic<uint64_t> silentFrames{0};
|
|
52
|
+
// Recent input level (0..1), guarded by mutex — drives the waveform.
|
|
53
|
+
double level = 0.0;
|
|
54
|
+
// Read from the JS thread.
|
|
55
|
+
std::atomic<bool> silenceDetected{false};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
RecorderState gRecorder;
|
|
59
|
+
|
|
60
|
+
void StopRecorderDevice() {
|
|
61
|
+
if (!gRecorder.recording.load() || !gRecorder.deviceInitialized.load()) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
ma_device_stop(&gRecorder.device);
|
|
65
|
+
ma_device_uninit(&gRecorder.device);
|
|
66
|
+
gRecorder.recording.store(false);
|
|
67
|
+
gRecorder.deviceInitialized.store(false);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
void CleanupRecorder(void*) {
|
|
71
|
+
StopRecorderDevice();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
bool GetBoolProperty(
|
|
75
|
+
napi_env env,
|
|
76
|
+
napi_value object,
|
|
77
|
+
const char* key,
|
|
78
|
+
bool fallback) {
|
|
79
|
+
bool hasProperty = false;
|
|
80
|
+
napi_has_named_property(env, object, key, &hasProperty);
|
|
81
|
+
if (!hasProperty) return fallback;
|
|
82
|
+
|
|
83
|
+
napi_value value;
|
|
84
|
+
napi_get_named_property(env, object, key, &value);
|
|
85
|
+
napi_valuetype type;
|
|
86
|
+
napi_typeof(env, value, &type);
|
|
87
|
+
if (type != napi_boolean) return fallback;
|
|
88
|
+
|
|
89
|
+
bool result = fallback;
|
|
90
|
+
napi_get_value_bool(env, value, &result);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
void Throw(napi_env env, const std::string& message) {
|
|
95
|
+
napi_throw_error(env, nullptr, message.c_str());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
uint32_t GetUint32Property(
|
|
99
|
+
napi_env env,
|
|
100
|
+
napi_value object,
|
|
101
|
+
const char* key,
|
|
102
|
+
uint32_t fallback) {
|
|
103
|
+
bool hasProperty = false;
|
|
104
|
+
napi_has_named_property(env, object, key, &hasProperty);
|
|
105
|
+
if (!hasProperty) return fallback;
|
|
106
|
+
|
|
107
|
+
napi_value value;
|
|
108
|
+
napi_get_named_property(env, object, key, &value);
|
|
109
|
+
napi_valuetype type;
|
|
110
|
+
napi_typeof(env, value, &type);
|
|
111
|
+
if (type != napi_number) return fallback;
|
|
112
|
+
|
|
113
|
+
uint32_t result = fallback;
|
|
114
|
+
napi_get_value_uint32(env, value, &result);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
void WriteU16(std::vector<uint8_t>& out, uint16_t value) {
|
|
119
|
+
out.push_back(static_cast<uint8_t>(value & 0xff));
|
|
120
|
+
out.push_back(static_cast<uint8_t>((value >> 8) & 0xff));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
void WriteU32(std::vector<uint8_t>& out, uint32_t value) {
|
|
124
|
+
out.push_back(static_cast<uint8_t>(value & 0xff));
|
|
125
|
+
out.push_back(static_cast<uint8_t>((value >> 8) & 0xff));
|
|
126
|
+
out.push_back(static_cast<uint8_t>((value >> 16) & 0xff));
|
|
127
|
+
out.push_back(static_cast<uint8_t>((value >> 24) & 0xff));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
void WriteAscii(std::vector<uint8_t>& out, const char* value) {
|
|
131
|
+
out.insert(out.end(), value, value + std::strlen(value));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
std::vector<uint8_t> ToWav(
|
|
135
|
+
const std::vector<int16_t>& pcm,
|
|
136
|
+
uint32_t sampleRate,
|
|
137
|
+
uint32_t channels) {
|
|
138
|
+
const uint16_t bitsPerSample = 16;
|
|
139
|
+
const uint16_t blockAlign = static_cast<uint16_t>(channels * 2);
|
|
140
|
+
const uint32_t byteRate = sampleRate * blockAlign;
|
|
141
|
+
const uint32_t dataBytes = static_cast<uint32_t>(pcm.size() * sizeof(int16_t));
|
|
142
|
+
|
|
143
|
+
std::vector<uint8_t> wav;
|
|
144
|
+
wav.reserve(44 + dataBytes);
|
|
145
|
+
WriteAscii(wav, "RIFF");
|
|
146
|
+
WriteU32(wav, 36 + dataBytes);
|
|
147
|
+
WriteAscii(wav, "WAVE");
|
|
148
|
+
WriteAscii(wav, "fmt ");
|
|
149
|
+
WriteU32(wav, 16);
|
|
150
|
+
WriteU16(wav, 1);
|
|
151
|
+
WriteU16(wav, static_cast<uint16_t>(channels));
|
|
152
|
+
WriteU32(wav, sampleRate);
|
|
153
|
+
WriteU32(wav, byteRate);
|
|
154
|
+
WriteU16(wav, blockAlign);
|
|
155
|
+
WriteU16(wav, bitsPerSample);
|
|
156
|
+
WriteAscii(wav, "data");
|
|
157
|
+
WriteU32(wav, dataBytes);
|
|
158
|
+
|
|
159
|
+
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(pcm.data());
|
|
160
|
+
wav.insert(wav.end(), bytes, bytes + dataBytes);
|
|
161
|
+
return wav;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
void DataCallback(
|
|
165
|
+
ma_device* device,
|
|
166
|
+
void* output,
|
|
167
|
+
const void* input,
|
|
168
|
+
ma_uint32 frameCount) {
|
|
169
|
+
(void)output;
|
|
170
|
+
if (!input) return;
|
|
171
|
+
|
|
172
|
+
auto* state = static_cast<RecorderState*>(device->pUserData);
|
|
173
|
+
const auto* samples = static_cast<const int16_t*>(input);
|
|
174
|
+
const size_t sampleCount = static_cast<size_t>(frameCount) * state->channels;
|
|
175
|
+
|
|
176
|
+
double sumAbs = 0.0;
|
|
177
|
+
for (size_t i = 0; i < sampleCount; ++i) {
|
|
178
|
+
sumAbs += std::abs(static_cast<double>(samples[i]));
|
|
179
|
+
}
|
|
180
|
+
const double meanAbs = sampleCount > 0 ? sumAbs / sampleCount : 0.0;
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
state->silenceDetectionEnabled.load() &&
|
|
184
|
+
!state->silenceDetected.load()) {
|
|
185
|
+
if (meanAbs >= kSilenceThreshold) {
|
|
186
|
+
state->speechStarted.store(true);
|
|
187
|
+
state->silentFrames.store(0);
|
|
188
|
+
} else if (state->speechStarted.load()) {
|
|
189
|
+
const uint64_t silentFrames =
|
|
190
|
+
state->silentFrames.load() + frameCount;
|
|
191
|
+
state->silentFrames.store(silentFrames);
|
|
192
|
+
const uint64_t needed =
|
|
193
|
+
static_cast<uint64_t>(state->sampleRate * kSilenceDurationSecs);
|
|
194
|
+
if (silentFrames >= needed) {
|
|
195
|
+
state->silenceDetected.store(true);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
std::lock_guard<std::mutex> lock(state->mutex);
|
|
201
|
+
state->level = meanAbs / 32768.0;
|
|
202
|
+
const size_t remaining = state->pcmSize < kMaxPcmSamples
|
|
203
|
+
? kMaxPcmSamples - state->pcmSize
|
|
204
|
+
: 0;
|
|
205
|
+
const size_t toCopy = std::min(sampleCount, remaining);
|
|
206
|
+
if (toCopy > 0) {
|
|
207
|
+
std::copy(samples, samples + toCopy, state->pcm.begin() + state->pcmSize);
|
|
208
|
+
state->pcmSize += toCopy;
|
|
209
|
+
}
|
|
210
|
+
if (toCopy < sampleCount) {
|
|
211
|
+
state->silenceDetected.store(true);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
napi_value StartRecording(napi_env env, napi_callback_info info) {
|
|
216
|
+
size_t argc = 1;
|
|
217
|
+
napi_value args[1];
|
|
218
|
+
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
|
|
219
|
+
|
|
220
|
+
if (gRecorder.recording.load()) {
|
|
221
|
+
Throw(env, "Native audio capture is already recording.");
|
|
222
|
+
return nullptr;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
uint32_t sampleRate = 16000;
|
|
226
|
+
uint32_t channels = 1;
|
|
227
|
+
bool silenceDetection = false;
|
|
228
|
+
if (argc > 0) {
|
|
229
|
+
napi_valuetype type;
|
|
230
|
+
napi_typeof(env, args[0], &type);
|
|
231
|
+
if (type == napi_object) {
|
|
232
|
+
sampleRate = GetUint32Property(env, args[0], "sampleRate", sampleRate);
|
|
233
|
+
channels = GetUint32Property(env, args[0], "channels", channels);
|
|
234
|
+
silenceDetection =
|
|
235
|
+
GetBoolProperty(env, args[0], "silenceDetection", silenceDetection);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (sampleRate == 0 || sampleRate > 192000 || channels == 0 ||
|
|
240
|
+
channels > 2) {
|
|
241
|
+
Throw(env, "Native audio capture requires 1 or 2 channels and a sample rate.");
|
|
242
|
+
return nullptr;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ma_device_config config = ma_device_config_init(ma_device_type_capture);
|
|
246
|
+
config.capture.format = ma_format_s16;
|
|
247
|
+
config.capture.channels = channels;
|
|
248
|
+
config.sampleRate = sampleRate;
|
|
249
|
+
config.dataCallback = DataCallback;
|
|
250
|
+
config.pUserData = &gRecorder;
|
|
251
|
+
|
|
252
|
+
{
|
|
253
|
+
std::lock_guard<std::mutex> lock(gRecorder.mutex);
|
|
254
|
+
gRecorder.sampleRate = sampleRate;
|
|
255
|
+
gRecorder.channels = channels;
|
|
256
|
+
gRecorder.silenceDetectionEnabled.store(silenceDetection);
|
|
257
|
+
gRecorder.speechStarted.store(false);
|
|
258
|
+
gRecorder.silentFrames.store(0);
|
|
259
|
+
gRecorder.level = 0.0;
|
|
260
|
+
gRecorder.silenceDetected.store(false);
|
|
261
|
+
if (gRecorder.pcm.size() < kMaxPcmSamples) {
|
|
262
|
+
gRecorder.pcm.resize(kMaxPcmSamples);
|
|
263
|
+
}
|
|
264
|
+
gRecorder.pcmSize = 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ma_result result = ma_device_init(nullptr, &config, &gRecorder.device);
|
|
268
|
+
if (result != MA_SUCCESS) {
|
|
269
|
+
Throw(env, "Native audio capture failed to initialize the input device.");
|
|
270
|
+
return nullptr;
|
|
271
|
+
}
|
|
272
|
+
gRecorder.deviceInitialized.store(true);
|
|
273
|
+
|
|
274
|
+
result = ma_device_start(&gRecorder.device);
|
|
275
|
+
if (result != MA_SUCCESS) {
|
|
276
|
+
ma_device_uninit(&gRecorder.device);
|
|
277
|
+
gRecorder.deviceInitialized.store(false);
|
|
278
|
+
Throw(env, "Native audio capture failed to start the input device.");
|
|
279
|
+
return nullptr;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
gRecorder.recording.store(true);
|
|
283
|
+
napi_value undefined;
|
|
284
|
+
napi_get_undefined(env, &undefined);
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
napi_value StopRecording(napi_env env, napi_callback_info info) {
|
|
289
|
+
(void)info;
|
|
290
|
+
if (!gRecorder.recording.load() || !gRecorder.deviceInitialized.load()) {
|
|
291
|
+
Throw(env, "Native audio capture is not recording.");
|
|
292
|
+
return nullptr;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
StopRecorderDevice();
|
|
296
|
+
|
|
297
|
+
std::vector<int16_t> pcm;
|
|
298
|
+
uint32_t sampleRate = 16000;
|
|
299
|
+
uint32_t channels = 1;
|
|
300
|
+
{
|
|
301
|
+
std::lock_guard<std::mutex> lock(gRecorder.mutex);
|
|
302
|
+
pcm.assign(gRecorder.pcm.begin(), gRecorder.pcm.begin() + gRecorder.pcmSize);
|
|
303
|
+
gRecorder.pcmSize = 0;
|
|
304
|
+
sampleRate = gRecorder.sampleRate;
|
|
305
|
+
channels = gRecorder.channels;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (pcm.empty()) {
|
|
309
|
+
Throw(env, "Native audio capture produced empty audio.");
|
|
310
|
+
return nullptr;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
std::vector<uint8_t> wav = ToWav(pcm, sampleRate, channels);
|
|
314
|
+
napi_value buffer;
|
|
315
|
+
napi_create_buffer_copy(env, wav.size(), wav.data(), nullptr, &buffer);
|
|
316
|
+
return buffer;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
napi_value IsRecording(napi_env env, napi_callback_info info) {
|
|
320
|
+
(void)info;
|
|
321
|
+
napi_value value;
|
|
322
|
+
napi_get_boolean(env, gRecorder.recording.load(), &value);
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
napi_value SilenceDetected(napi_env env, napi_callback_info info) {
|
|
327
|
+
(void)info;
|
|
328
|
+
napi_value value;
|
|
329
|
+
napi_get_boolean(env, gRecorder.silenceDetected.load(), &value);
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Return (and clear) PCM captured since the last call — for streaming uploads.
|
|
334
|
+
napi_value DrainAudio(napi_env env, napi_callback_info info) {
|
|
335
|
+
(void)info;
|
|
336
|
+
std::vector<int16_t> pcm;
|
|
337
|
+
{
|
|
338
|
+
std::lock_guard<std::mutex> lock(gRecorder.mutex);
|
|
339
|
+
pcm.assign(gRecorder.pcm.begin(), gRecorder.pcm.begin() + gRecorder.pcmSize);
|
|
340
|
+
gRecorder.pcmSize = 0;
|
|
341
|
+
}
|
|
342
|
+
napi_value buffer;
|
|
343
|
+
const size_t bytes = pcm.size() * sizeof(int16_t);
|
|
344
|
+
napi_create_buffer_copy(
|
|
345
|
+
env,
|
|
346
|
+
bytes,
|
|
347
|
+
bytes == 0 ? nullptr : reinterpret_cast<const uint8_t*>(pcm.data()),
|
|
348
|
+
nullptr,
|
|
349
|
+
&buffer);
|
|
350
|
+
return buffer;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Recent input level (0..1) for the waveform display.
|
|
354
|
+
napi_value AudioLevel(napi_env env, napi_callback_info info) {
|
|
355
|
+
(void)info;
|
|
356
|
+
double level;
|
|
357
|
+
{
|
|
358
|
+
std::lock_guard<std::mutex> lock(gRecorder.mutex);
|
|
359
|
+
level = gRecorder.level;
|
|
360
|
+
}
|
|
361
|
+
napi_value value;
|
|
362
|
+
napi_create_double(env, level, &value);
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
napi_value MicrophoneAuthorizationStatus(
|
|
367
|
+
napi_env env,
|
|
368
|
+
napi_callback_info info) {
|
|
369
|
+
(void)info;
|
|
370
|
+
const char* status = "unknown";
|
|
371
|
+
#if defined(__APPLE__)
|
|
372
|
+
switch (qwen_mic_authorization_status()) {
|
|
373
|
+
case 3:
|
|
374
|
+
status = "granted";
|
|
375
|
+
break;
|
|
376
|
+
case 2: // denied
|
|
377
|
+
case 1: // restricted (e.g. MDM) — unusable, treat as denied
|
|
378
|
+
status = "denied";
|
|
379
|
+
break;
|
|
380
|
+
case 0:
|
|
381
|
+
status = "prompt"; // not yet determined; capture will trigger the dialog
|
|
382
|
+
break;
|
|
383
|
+
default:
|
|
384
|
+
status = "unknown";
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
#endif
|
|
388
|
+
napi_value value;
|
|
389
|
+
napi_create_string_utf8(env, status, NAPI_AUTO_LENGTH, &value);
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
napi_value Init(napi_env env, napi_value exports) {
|
|
394
|
+
napi_add_env_cleanup_hook(env, CleanupRecorder, nullptr);
|
|
395
|
+
napi_property_descriptor descriptors[] = {
|
|
396
|
+
{"startRecording", nullptr, StartRecording, nullptr, nullptr, nullptr,
|
|
397
|
+
napi_default, nullptr},
|
|
398
|
+
{"stopRecording", nullptr, StopRecording, nullptr, nullptr, nullptr,
|
|
399
|
+
napi_default, nullptr},
|
|
400
|
+
{"isRecording", nullptr, IsRecording, nullptr, nullptr, nullptr,
|
|
401
|
+
napi_default, nullptr},
|
|
402
|
+
{"silenceDetected", nullptr, SilenceDetected, nullptr, nullptr, nullptr,
|
|
403
|
+
napi_default, nullptr},
|
|
404
|
+
{"drainAudio", nullptr, DrainAudio, nullptr, nullptr, nullptr,
|
|
405
|
+
napi_default, nullptr},
|
|
406
|
+
{"audioLevel", nullptr, AudioLevel, nullptr, nullptr, nullptr,
|
|
407
|
+
napi_default, nullptr},
|
|
408
|
+
{"microphoneAuthorizationStatus", nullptr, MicrophoneAuthorizationStatus,
|
|
409
|
+
nullptr, nullptr, nullptr, napi_default, nullptr},
|
|
410
|
+
};
|
|
411
|
+
napi_define_properties(
|
|
412
|
+
env,
|
|
413
|
+
exports,
|
|
414
|
+
sizeof(descriptors) / sizeof(descriptors[0]),
|
|
415
|
+
descriptors);
|
|
416
|
+
return exports;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
} // namespace
|
|
420
|
+
|
|
421
|
+
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// macOS microphone authorization query via AVFoundation. Compiled only on mac
|
|
8
|
+
// (see binding.gyp). Returns the AVAuthorizationStatus as a small int so the
|
|
9
|
+
// C++ addon can map it without linking ObjC itself:
|
|
10
|
+
// 0 = notDetermined, 1 = restricted, 2 = denied, 3 = authorized
|
|
11
|
+
#import <AVFoundation/AVFoundation.h>
|
|
12
|
+
|
|
13
|
+
extern "C" int qwen_mic_authorization_status(void) {
|
|
14
|
+
if (@available(macOS 10.14, *)) {
|
|
15
|
+
AVAuthorizationStatus status =
|
|
16
|
+
[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
|
|
17
|
+
switch (status) {
|
|
18
|
+
case AVAuthorizationStatusAuthorized:
|
|
19
|
+
return 3;
|
|
20
|
+
case AVAuthorizationStatusDenied:
|
|
21
|
+
return 2;
|
|
22
|
+
case AVAuthorizationStatusRestricted:
|
|
23
|
+
return 1;
|
|
24
|
+
case AVAuthorizationStatusNotDetermined:
|
|
25
|
+
default:
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Pre-10.14 had no mic gate — treat as granted.
|
|
30
|
+
return 3;
|
|
31
|
+
}
|