-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathbridge.ts
More file actions
160 lines (138 loc) · 4.5 KB
/
Copy pathbridge.ts
File metadata and controls
160 lines (138 loc) · 4.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
// Base adapter for plugging any voice stack into facetime-bridge.
// See docs/INTEGRATION.md for the full contract and examples.
import { join } from "node:path";
import { homedir } from "node:os";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const PROTO_PATH = join(
import.meta.dir,
"../native/Sources/FaceTimeBridge/Protos/facetime_media.proto",
);
/** Daemon socket: FACETIME_BRIDGE_SOCKET env override, else the daemon default. */
export function defaultSocketPath(): string {
return process.env.FACETIME_BRIDGE_SOCKET ?? join(homedir(), ".facetime-bridge", "bridge.sock");
}
export type ControlCommand = "probe" | "call" | "answer" | "hangup";
export type ControlReply = {
ok: boolean;
state: string;
authorized: boolean;
action: string;
errorCode: string;
message: string;
};
export type CallEvent = {
state: string;
authorized: boolean;
errorCode: string;
observedAtMs: string | number;
};
export type AudioPacket = {
callId: string;
kind: number;
pcm16: Buffer;
sampleRate: number;
channels: number;
sequence: string | number;
event: string;
};
export type HealthReply = {
ready: boolean;
inputDevice: string;
outputDevice: string;
};
/** Audio transport contract: 24 kHz, mono, signed 16-bit little-endian PCM. */
export const audioFormat = {
sampleRate: 24_000,
channels: 1,
encoding: "s16le",
/**
* Nominal daemon packet cadence: ~20 ms => 480 samples => 960 bytes
* (AudioBridge.swift taps 960 frames at 48 kHz and halves to 24 kHz).
* AVAudioEngine may coalesce buffers - reframe by sample count, never
* by packet boundaries.
*/
bytesPerPacket: 960,
} as const;
export const audioPacketKind = {
start: 1,
capture: 2,
playback: 3,
clear: 4,
stop: 5,
event: 6,
} as const;
type UnaryCallback<T> = (error: grpc.ServiceError | null, value: T) => void;
interface FaceTimeMediaClient extends grpc.Client {
health(request: Record<string, never>, callback: UnaryCallback<HealthReply>): void;
control(request: { command: number }, callback: UnaryCallback<ControlReply>): void;
waitIncoming(request: Record<string, never>): grpc.ClientReadableStream<CallEvent>;
audio(): grpc.ClientDuplexStream<AudioPacket, AudioPacket>;
}
type FaceTimeMediaClientConstructor = new (
address: string,
credentials: grpc.ChannelCredentials,
) => FaceTimeMediaClient;
type LoadedContract = {
facetimebridge: {
v1: {
FaceTimeMedia: FaceTimeMediaClientConstructor;
};
};
};
const definition = protoLoader.loadSync(PROTO_PATH, {
defaults: true,
enums: Number,
longs: String,
oneofs: true,
});
// proto-loader returns a runtime-generated object whose shape is fixed by the checked-in proto.
const contract = grpc.loadPackageDefinition(definition) as unknown as LoadedContract;
const Client = contract.facetimebridge.v1.FaceTimeMedia;
const commandValues: Record<ControlCommand, number> = {
probe: 1,
call: 2,
answer: 3,
hangup: 4,
};
/**
* Thin, dependency-light client over the daemon's Unix-socket gRPC surface.
* One instance per consumer; call close() when done.
*/
export class FaceTimeBridge {
private readonly client: FaceTimeMediaClient;
constructor(socketPath: string = defaultSocketPath()) {
this.client = new Client(`unix:${socketPath}`, grpc.credentials.createInsecure());
}
/** Daemon readiness plus the exact BlackHole devices it bound. */
health(): Promise<HealthReply> {
const gate = Promise.withResolvers<HealthReply>();
this.client.health({}, (error, value) => (error ? gate.reject(error) : gate.resolve(value)));
return gate.promise;
}
/**
* Semantic call control. Every mutating command is authority-gated by the
* daemon: unconfigured or unauthorized targets fail closed with an errorCode.
*/
control(command: ControlCommand): Promise<ControlReply> {
const gate = Promise.withResolvers<ControlReply>();
this.client.control({ command: commandValues[command] }, (error, value) =>
error ? gate.reject(error) : gate.resolve(value),
);
return gate.promise;
}
/** Server stream of call-state transitions (ring, connected, ended, ...). */
waitIncoming(): grpc.ClientReadableStream<CallEvent> {
return this.client.waitIncoming({});
}
/**
* Bidirectional audio. Read `capture` packets (caller voice), write
* `playback` packets (your agent's voice) in the `audioFormat` contract.
*/
audio(): grpc.ClientDuplexStream<AudioPacket, AudioPacket> {
return this.client.audio();
}
close(): void {
this.client.close();
}
}