pi-meta-oauth 0.1.1 → 0.2.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 +49 -20
- package/extensions/voice/Entitlements.plist +8 -0
- package/extensions/voice/Info.plist +22 -0
- package/extensions/voice/macos-audio.swift +182 -0
- package/extensions/voice.ts +845 -0
- package/package.json +12 -7
- package/tests/meta.test.ts +31 -15
- package/tests/voice.test.ts +55 -0
- package/tsconfig.json +1 -1
- package/bun.lock +0 -296
package/README.md
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
+
<img width="3840" height="2160" alt="Meta-Symbol" src="https://github.com/user-attachments/assets/a3074df9-ad80-40ca-b9b9-944b96dda192" />
|
|
2
|
+
|
|
1
3
|
# pi-meta-oauth
|
|
2
4
|
|
|
3
|
-
Meta Model API OAuth
|
|
5
|
+
Meta Model API OAuth and Muse-style voice input for [pi](https://pi.dev).
|
|
4
6
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- Use Muse Spark models through Pi's `openai-responses` provider
|
|
8
|
+
- Device authorization against `https://auth.meta.com`
|
|
9
|
+
- Model API-key minting through `POST https://api.meta.ai/muse-code/key`
|
|
10
|
+
- Dynamic Muse model catalog from `GET https://api.meta.ai/v1/models`
|
|
11
|
+
- Toggle-based Meta voice dictation on macOS with a live green input meter
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
|
-
#
|
|
15
|
-
pi install
|
|
16
|
-
|
|
16
|
+
# Local checkout
|
|
17
|
+
pi install /absolute/path/to/pi-meta-oauth
|
|
18
|
+
|
|
19
|
+
# npm
|
|
17
20
|
pi install npm:pi-meta-oauth
|
|
18
21
|
|
|
19
22
|
pi --list-models meta
|
|
@@ -21,29 +24,51 @@ pi --list-models meta
|
|
|
21
24
|
|
|
22
25
|
## Login
|
|
23
26
|
|
|
24
|
-
```
|
|
25
|
-
pi
|
|
27
|
+
```text
|
|
26
28
|
/login meta
|
|
27
|
-
# shows device code -> approve in browser -> mints Model API key automatically
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
Pi displays a device code, opens the Meta authorization flow, and mints a Model API key. Credentials are stored by Pi in `~/.pi/agent/auth.json` under provider `meta`:
|
|
31
32
|
|
|
32
33
|
```json
|
|
33
34
|
{ "meta": { "type": "oauth", "refresh": "<identity>", "access": "<MODEL_API_KEY>", "expires": 123 } }
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
The access key is re-minted daily.
|
|
38
|
+
|
|
39
|
+
## Voice input
|
|
40
|
+
|
|
41
|
+
Voice mode currently requires macOS. It uses the same Meta credential managed by this provider; a separate Muse login is not required.
|
|
42
|
+
|
|
43
|
+
1. Press **Alt+V** once to start recording.
|
|
44
|
+
2. Speak while the green microphone meter is visible in Pi's status bar.
|
|
45
|
+
3. Press **Alt+V** again to stop.
|
|
46
|
+
4. The transcript is inserted into Pi's editor for review before submission.
|
|
47
|
+
|
|
48
|
+
Commands:
|
|
49
|
+
|
|
50
|
+
- `/voice` — show voice status and privacy behavior
|
|
51
|
+
- `/voice-on` — enable the Alt+V shortcut
|
|
52
|
+
- `/voice-off` — disable voice input
|
|
53
|
+
|
|
54
|
+
The first recording may trigger the macOS microphone permission prompt. Audio is captured only between the two Alt+V presses and streamed as 16 kHz mono PCM to Muse Code's internal Meta ASR endpoint. No local speech-recognition model is downloaded. The endpoint is undocumented and may change in a future Muse release.
|
|
55
|
+
|
|
56
|
+
Optional overrides:
|
|
57
|
+
|
|
58
|
+
- `PI_META_VOICE_ASR_ENDPOINT`
|
|
59
|
+
- `PI_META_VOICE_ASR_MODEL`
|
|
60
|
+
|
|
36
61
|
## Models
|
|
37
62
|
|
|
38
|
-
|
|
63
|
+
Fallback models use a 1,048,576-token context window, up to 256K output tokens, image input, and reasoning levels `minimal`, `low`, `medium`, `high`, and `xhigh`.
|
|
39
64
|
|
|
40
65
|
| id | pricing (input/output/cached) $/M |
|
|
41
|
-
|
|
66
|
+
| --- | --- |
|
|
42
67
|
| `muse-spark-1.2` | 1.25 / 4.25 / 0.15 |
|
|
43
68
|
| `muse-spark-1.2-contributor` | 0.10 / 0.20 / 0.002 |
|
|
44
69
|
| `muse-spark-1.1` | 1.25 / 4.25 / 0.15 |
|
|
45
70
|
|
|
46
|
-
|
|
71
|
+
To scope Pi's model picker to Meta models:
|
|
47
72
|
|
|
48
73
|
```json
|
|
49
74
|
{ "enabledModels": ["meta/*"] }
|
|
@@ -54,16 +79,20 @@ Add to `enabledModels` in `~/.pi/agent/settings.json`:
|
|
|
54
79
|
```bash
|
|
55
80
|
pi --list-models meta
|
|
56
81
|
pi -p --provider meta --model muse-spark-1.2 "Reply exactly: META_OK"
|
|
57
|
-
bun
|
|
82
|
+
bun run typecheck
|
|
83
|
+
bun test
|
|
58
84
|
```
|
|
59
85
|
|
|
60
86
|
## Publish to pi.dev
|
|
61
87
|
|
|
62
|
-
`pi-package` keyword makes
|
|
88
|
+
The `pi-package` keyword makes the package discoverable at <https://pi.dev/packages>.
|
|
63
89
|
|
|
64
90
|
```bash
|
|
65
91
|
npm publish --access public
|
|
66
|
-
# users then run: pi install npm:pi-meta-oauth
|
|
67
92
|
```
|
|
68
93
|
|
|
69
|
-
|
|
94
|
+
Users can then update with:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pi update npm:pi-meta-oauth
|
|
98
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
6
|
+
<string>en</string>
|
|
7
|
+
<key>CFBundleDisplayName</key>
|
|
8
|
+
<string>Pi Meta OAuth Voice</string>
|
|
9
|
+
<key>CFBundleExecutable</key>
|
|
10
|
+
<string>pi-meta-oauth-voice-v1</string>
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>dev.pi.metaoauth.voice</string>
|
|
13
|
+
<key>CFBundleName</key>
|
|
14
|
+
<string>Pi Meta OAuth Voice</string>
|
|
15
|
+
<key>CFBundlePackageType</key>
|
|
16
|
+
<string>APPL</string>
|
|
17
|
+
<key>CFBundleShortVersionString</key>
|
|
18
|
+
<string>2.0</string>
|
|
19
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
20
|
+
<string>Pi records audio after Alt+V starts voice input and stops when Alt+V is pressed again, then sends it to Meta ASR for transcription.</string>
|
|
21
|
+
</dict>
|
|
22
|
+
</plist>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
private let outputQueue = DispatchQueue(label: "com.pi.muse.meta-voice.output")
|
|
5
|
+
|
|
6
|
+
private func emitNow(_ type: String, _ fields: [String: Any] = [:]) {
|
|
7
|
+
var payload = fields
|
|
8
|
+
payload["type"] = type
|
|
9
|
+
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
10
|
+
var line = String(data: data, encoding: .utf8) else {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
line.append("\n")
|
|
14
|
+
FileHandle.standardOutput.write(Data(line.utf8))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private func emit(_ type: String, _ fields: [String: Any] = [:]) {
|
|
18
|
+
outputQueue.async {
|
|
19
|
+
emitNow(type, fields)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private final class MicrophoneSession {
|
|
24
|
+
private let audioEngine = AVAudioEngine()
|
|
25
|
+
private var converter: AVAudioConverter?
|
|
26
|
+
private var stopping = false
|
|
27
|
+
private var finished = false
|
|
28
|
+
|
|
29
|
+
func start() {
|
|
30
|
+
requestMicrophoneAccess()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private func requestMicrophoneAccess() {
|
|
34
|
+
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
35
|
+
case .authorized:
|
|
36
|
+
beginCapture()
|
|
37
|
+
case .notDetermined:
|
|
38
|
+
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
|
39
|
+
DispatchQueue.main.async {
|
|
40
|
+
guard let self else { return }
|
|
41
|
+
if granted {
|
|
42
|
+
self.beginCapture()
|
|
43
|
+
} else {
|
|
44
|
+
self.fail("Microphone permission was not granted. Enable it in System Settings → Privacy & Security → Microphone.", exitCode: 2)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
fail("Microphone permission is disabled. Enable it in System Settings → Privacy & Security → Microphone.", exitCode: 2)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func beginCapture() {
|
|
54
|
+
let inputNode = audioEngine.inputNode
|
|
55
|
+
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
56
|
+
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
|
57
|
+
fail("The selected microphone returned an invalid audio format.", exitCode: 3)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
guard let outputFormat = AVAudioFormat(
|
|
61
|
+
commonFormat: .pcmFormatInt16,
|
|
62
|
+
sampleRate: 16_000,
|
|
63
|
+
channels: 1,
|
|
64
|
+
interleaved: false
|
|
65
|
+
), let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
|
|
66
|
+
fail("Could not initialize 16 kHz microphone conversion.", exitCode: 3)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
self.converter = converter
|
|
70
|
+
|
|
71
|
+
let tap: AVAudioNodeTapBlock = { [weak self] buffer, _ in
|
|
72
|
+
self?.convertAndEmit(buffer, outputFormat: outputFormat)
|
|
73
|
+
}
|
|
74
|
+
let tapBufferSize = AVAudioFrameCount(inputFormat.sampleRate * 0.1)
|
|
75
|
+
do {
|
|
76
|
+
if #available(macOS 27.0, *) {
|
|
77
|
+
try inputNode.__installTap(
|
|
78
|
+
onBus: 0,
|
|
79
|
+
bufferSize: tapBufferSize,
|
|
80
|
+
format: inputFormat,
|
|
81
|
+
error: (),
|
|
82
|
+
block: tap
|
|
83
|
+
)
|
|
84
|
+
} else {
|
|
85
|
+
inputNode.installTap(
|
|
86
|
+
onBus: 0,
|
|
87
|
+
bufferSize: tapBufferSize,
|
|
88
|
+
format: inputFormat,
|
|
89
|
+
block: tap
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
audioEngine.prepare()
|
|
93
|
+
try audioEngine.start()
|
|
94
|
+
} catch {
|
|
95
|
+
inputNode.removeTap(onBus: 0)
|
|
96
|
+
fail("Could not start microphone capture: \(error.localizedDescription)", exitCode: 4)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
emit("ready", [
|
|
101
|
+
"sampleRate": 16_000,
|
|
102
|
+
"channels": 1,
|
|
103
|
+
"encoding": "pcm_s16le"
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
107
|
+
_ = readLine()
|
|
108
|
+
DispatchQueue.main.async {
|
|
109
|
+
self?.stop()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func convertAndEmit(_ input: AVAudioPCMBuffer, outputFormat: AVAudioFormat) {
|
|
115
|
+
guard !stopping, !finished, let converter else { return }
|
|
116
|
+
let ratio = outputFormat.sampleRate / input.format.sampleRate
|
|
117
|
+
let capacity = AVAudioFrameCount(ceil(Double(input.frameLength) * ratio)) + 32
|
|
118
|
+
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
var suppliedInput = false
|
|
123
|
+
var conversionError: NSError?
|
|
124
|
+
let status = converter.convert(to: output, error: &conversionError) { _, inputStatus in
|
|
125
|
+
if suppliedInput {
|
|
126
|
+
inputStatus.pointee = .noDataNow
|
|
127
|
+
return nil
|
|
128
|
+
}
|
|
129
|
+
suppliedInput = true
|
|
130
|
+
inputStatus.pointee = .haveData
|
|
131
|
+
return input
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if let conversionError {
|
|
135
|
+
emit("error", ["message": "Microphone conversion failed: \(conversionError.localizedDescription)"])
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
guard status != .error,
|
|
139
|
+
output.frameLength > 0,
|
|
140
|
+
let samples = output.int16ChannelData?[0] else {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let byteCount = Int(output.frameLength) * MemoryLayout<Int16>.size
|
|
145
|
+
let audio = Data(bytes: samples, count: byteCount)
|
|
146
|
+
emit("audio", ["data": audio.base64EncodedString()])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func stop() {
|
|
150
|
+
guard !stopping, !finished else { return }
|
|
151
|
+
stopping = true
|
|
152
|
+
if audioEngine.isRunning {
|
|
153
|
+
audioEngine.stop()
|
|
154
|
+
}
|
|
155
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
156
|
+
converter = nil
|
|
157
|
+
finished = true
|
|
158
|
+
outputQueue.async {
|
|
159
|
+
emitNow("stopped")
|
|
160
|
+
fflush(stdout)
|
|
161
|
+
exit(0)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func fail(_ message: String, exitCode: Int32) {
|
|
166
|
+
guard !finished else { return }
|
|
167
|
+
finished = true
|
|
168
|
+
if audioEngine.isRunning {
|
|
169
|
+
audioEngine.stop()
|
|
170
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
171
|
+
}
|
|
172
|
+
outputQueue.async {
|
|
173
|
+
emitNow("error", ["message": message])
|
|
174
|
+
fflush(stdout)
|
|
175
|
+
exit(exitCode)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private let session = MicrophoneSession()
|
|
181
|
+
session.start()
|
|
182
|
+
RunLoop.main.run()
|