@kernl-sdk/react 0.0.1

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.
@@ -0,0 +1,173 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { RealtimeSession, RealtimeAgent, Context } from "kernl";
3
+ import type {
4
+ RealtimeModel,
5
+ RealtimeChannel,
6
+ ClientCredential,
7
+ TransportStatus,
8
+ } from "@kernl-sdk/protocol";
9
+
10
+ /**
11
+ * Credential input that accepts expiresAt as either Date or string.
12
+ * Derived from ClientCredential to stay in sync.
13
+ */
14
+ type FlexibleExpiry<T> = T extends { expiresAt: Date }
15
+ ? Omit<T, "expiresAt"> & { expiresAt: Date | string }
16
+ : never;
17
+
18
+ export type CredentialInput = FlexibleExpiry<ClientCredential>;
19
+
20
+ /**
21
+ * Options for the useRealtime hook.
22
+ */
23
+ export interface UseRealtimeOptions<TContext> {
24
+ /**
25
+ * The realtime model to use.
26
+ */
27
+ model: RealtimeModel;
28
+
29
+ /**
30
+ * Audio I/O channel for mic capture and playback.
31
+ */
32
+ channel?: RealtimeChannel;
33
+
34
+ /**
35
+ * Context passed to tool executions.
36
+ */
37
+ ctx?: TContext;
38
+ }
39
+
40
+ /**
41
+ * Return value from the useRealtime hook.
42
+ */
43
+ export interface UseRealtimeReturn {
44
+ /**
45
+ * Current connection status.
46
+ */
47
+ status: TransportStatus;
48
+
49
+ /**
50
+ * Connect to the realtime model with the given credential.
51
+ */
52
+ connect: (credential: CredentialInput) => Promise<void>;
53
+
54
+ /**
55
+ * Disconnect from the realtime model.
56
+ */
57
+ disconnect: () => void;
58
+
59
+ /**
60
+ * Whether audio input is muted.
61
+ */
62
+ muted: boolean;
63
+
64
+ /**
65
+ * Mute audio input.
66
+ */
67
+ mute: () => void;
68
+
69
+ /**
70
+ * Unmute audio input.
71
+ */
72
+ unmute: () => void;
73
+
74
+ /**
75
+ * Send a text message to the model.
76
+ */
77
+ sendMessage: (text: string) => void;
78
+ }
79
+
80
+ /**
81
+ * React hook for managing a realtime voice session.
82
+ *
83
+ * Handles connection lifecycle, status updates, and cleanup on unmount.
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * const { status, connect, disconnect } = useRealtime(agent, {
88
+ * model: openai.realtime("gpt-4o-realtime"),
89
+ * channel,
90
+ * ctx: { setCart },
91
+ * });
92
+ *
93
+ * const start = async () => {
94
+ * const { credential } = await fetch("/api/credential").then(r => r.json());
95
+ * await channel.init();
96
+ * connect(credential);
97
+ * };
98
+ * ```
99
+ */
100
+ export function useRealtime<TContext>(
101
+ agent: RealtimeAgent<TContext>,
102
+ options: UseRealtimeOptions<TContext>,
103
+ ): UseRealtimeReturn {
104
+ const [status, setStatus] = useState<TransportStatus>("disconnected");
105
+ const [muted, setMuted] = useState(false);
106
+ const sessionRef = useRef<RealtimeSession<TContext> | null>(null);
107
+
108
+ const connect = useCallback(
109
+ async (input: CredentialInput) => {
110
+ if (sessionRef.current) return;
111
+
112
+ // Convert expiresAt to Date if needed
113
+ const expiresAt =
114
+ typeof input.expiresAt === "string"
115
+ ? new Date(input.expiresAt)
116
+ : input.expiresAt;
117
+
118
+ const credential: ClientCredential =
119
+ input.kind === "token"
120
+ ? { kind: "token", token: input.token, expiresAt }
121
+ : { kind: "url", url: input.url, expiresAt };
122
+
123
+ const session = new RealtimeSession(agent, {
124
+ model: options.model,
125
+ credential,
126
+ channel: options.channel,
127
+ context: options.ctx
128
+ ? (new Context("react", options.ctx) as Context<TContext>)
129
+ : undefined,
130
+ });
131
+
132
+ // Ignore events from sessions we've already disconnected from
133
+ session.on("status", (s) => {
134
+ if (sessionRef.current === session) {
135
+ setStatus(s);
136
+ }
137
+ });
138
+ sessionRef.current = session;
139
+ await session.connect();
140
+ },
141
+ [agent, options.model, options.channel, options.ctx],
142
+ );
143
+
144
+ const disconnect = useCallback(() => {
145
+ sessionRef.current?.close();
146
+ sessionRef.current = null;
147
+ setStatus("disconnected");
148
+ setMuted(false);
149
+ }, []);
150
+
151
+ const mute = useCallback(() => {
152
+ sessionRef.current?.mute();
153
+ setMuted(true);
154
+ }, []);
155
+
156
+ const unmute = useCallback(() => {
157
+ sessionRef.current?.unmute();
158
+ setMuted(false);
159
+ }, []);
160
+
161
+ const sendMessage = useCallback((text: string) => {
162
+ sessionRef.current?.sendMessage(text);
163
+ }, []);
164
+
165
+ // cleanup
166
+ useEffect(() => {
167
+ return () => {
168
+ sessionRef.current?.close();
169
+ };
170
+ }, []);
171
+
172
+ return { status, connect, disconnect, muted, mute, unmute, sendMessage };
173
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // hooks
2
+ export { useRealtime } from "./hooks/use-realtime";
3
+ export type {
4
+ UseRealtimeOptions,
5
+ UseRealtimeReturn,
6
+ CredentialInput,
7
+ } from "./hooks/use-realtime";
8
+
9
+ export { useBrowserAudio } from "./hooks/use-browser-audio";
10
+ export type { UseBrowserAudioReturn } from "./hooks/use-browser-audio";
11
+
12
+ // components
13
+ export { LiveWaveform } from "./components/live-waveform";
14
+ export type { LiveWaveformProps, AudioSource } from "./components/live-waveform";
15
+
16
+ // lib
17
+ export { BrowserChannel } from "./lib/browser-channel";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * AudioWorklet processor for capturing and resampling audio.
3
+ *
4
+ * This runs on the audio rendering thread for low-latency processing.
5
+ * Resamples from device sample rate to target rate (24kHz for realtime API).
6
+ */
7
+
8
+ // This code runs inside an AudioWorkletGlobalScope
9
+ const workletCode = `
10
+ const TARGET_SAMPLE_RATE = 24000;
11
+
12
+ class AudioCaptureProcessor extends AudioWorkletProcessor {
13
+ constructor() {
14
+ super();
15
+ this.resampleBuffer = [];
16
+ this.resampleRatio = sampleRate / TARGET_SAMPLE_RATE;
17
+ }
18
+
19
+ process(inputs) {
20
+ const input = inputs[0];
21
+ if (!input || !input[0]) return true;
22
+
23
+ const inputData = input[0];
24
+
25
+ // Resample using linear interpolation
26
+ const resampled = this.resample(inputData);
27
+
28
+ // Convert to PCM16
29
+ const pcm16 = new Int16Array(resampled.length);
30
+ for (let i = 0; i < resampled.length; i++) {
31
+ const s = Math.max(-1, Math.min(1, resampled[i]));
32
+ pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
33
+ }
34
+
35
+ // Send to main thread
36
+ this.port.postMessage({ pcm16: pcm16.buffer }, [pcm16.buffer]);
37
+
38
+ return true;
39
+ }
40
+
41
+ resample(input) {
42
+ // Add input to buffer
43
+ for (let i = 0; i < input.length; i++) {
44
+ this.resampleBuffer.push(input[i]);
45
+ }
46
+
47
+ // Calculate how many output samples we can produce
48
+ const outputLength = Math.floor(this.resampleBuffer.length / this.resampleRatio);
49
+ if (outputLength === 0) return new Float32Array(0);
50
+
51
+ const output = new Float32Array(outputLength);
52
+
53
+ for (let i = 0; i < outputLength; i++) {
54
+ const srcIndex = i * this.resampleRatio;
55
+ const srcIndexFloor = Math.floor(srcIndex);
56
+ const srcIndexCeil = Math.min(srcIndexFloor + 1, this.resampleBuffer.length - 1);
57
+ const t = srcIndex - srcIndexFloor;
58
+
59
+ // Linear interpolation
60
+ output[i] = this.resampleBuffer[srcIndexFloor] * (1 - t) +
61
+ this.resampleBuffer[srcIndexCeil] * t;
62
+ }
63
+
64
+ // Remove consumed samples from buffer
65
+ const consumed = Math.floor(outputLength * this.resampleRatio);
66
+ this.resampleBuffer = this.resampleBuffer.slice(consumed);
67
+
68
+ return output;
69
+ }
70
+ }
71
+
72
+ registerProcessor('audio-capture-processor', AudioCaptureProcessor);
73
+ `;
74
+
75
+ /**
76
+ * Create a blob URL for the audio worklet processor.
77
+ * This allows loading the worklet without a separate file.
78
+ */
79
+ export function createWorkletUrl(): string {
80
+ const blob = new Blob([workletCode], { type: "application/javascript" });
81
+ return URL.createObjectURL(blob);
82
+ }
@@ -0,0 +1,178 @@
1
+ import type {
2
+ RealtimeChannel,
3
+ RealtimeChannelEvents,
4
+ } from "@kernl-sdk/protocol";
5
+ import { Emitter, base64ToPcm16, pcm16ToFloat32 } from "@kernl-sdk/shared";
6
+
7
+ import { createWorkletUrl } from "./audio-capture-worklet";
8
+
9
+ /** Standard wire format sample rate for realtime audio (PCM16). */
10
+ const WIRE_FORMAT_SAMPLE_RATE = 24000;
11
+
12
+ /** Lookahead buffer to prevent gaps from network jitter (seconds). */
13
+ const PLAYBACK_LOOKAHEAD = 0.05;
14
+
15
+ /**
16
+ * Browser-based audio channel for realtime voice sessions.
17
+ *
18
+ * Uses the standard wire format (24kHz PCM16 base64) for audio I/O.
19
+ * Captures microphone audio and plays received audio through Web Audio API.
20
+ * Resamples from device sample rate to wire format using AudioWorklet.
21
+ */
22
+ export class BrowserChannel
23
+ extends Emitter<RealtimeChannelEvents>
24
+ implements RealtimeChannel
25
+ {
26
+ private audioContext: AudioContext | null = null;
27
+ private mediaStream: MediaStream | null = null;
28
+ private workletNode: AudioWorkletNode | null = null;
29
+ private workletUrl: string | null = null;
30
+ private nextPlayTime = 0;
31
+ private activeSources: AudioBufferSourceNode[] = [];
32
+ private _output: AnalyserNode | null = null;
33
+ private _input: AnalyserNode | null = null;
34
+
35
+ /**
36
+ * Initialize audio context and start capturing from the microphone.
37
+ */
38
+ async init(): Promise<void> {
39
+ this.audioContext = new AudioContext();
40
+
41
+ // resume AudioContext (required after user gesture in some browsers)
42
+ if (this.audioContext.state === "suspended") {
43
+ await this.audioContext.resume();
44
+ }
45
+
46
+ // get microphone stream
47
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
48
+ audio: {
49
+ echoCancellation: true,
50
+ noiseSuppression: true,
51
+ autoGainControl: true,
52
+ },
53
+ });
54
+
55
+ // Load AudioWorklet processor (resamples from device rate to wire format)
56
+ this.workletUrl = createWorkletUrl();
57
+ await this.audioContext.audioWorklet.addModule(this.workletUrl);
58
+
59
+ // Create worklet node
60
+ this.workletNode = new AudioWorkletNode(
61
+ this.audioContext,
62
+ "audio-capture-processor",
63
+ );
64
+
65
+ // Handle resampled PCM16 audio from worklet
66
+ this.workletNode.port.onmessage = (event) => {
67
+ const pcm16 = new Int16Array(event.data.pcm16);
68
+ if (pcm16.length === 0) return;
69
+
70
+ const base64 = base64ToPcm16.decode(pcm16);
71
+ this.emit("audio", base64);
72
+ };
73
+
74
+ // Create input analyser for mic visualization
75
+ this._input = this.audioContext.createAnalyser();
76
+ this._input.fftSize = 256;
77
+ this._input.smoothingTimeConstant = 0.5;
78
+
79
+ // Connect: mic → input analyser (for viz) and mic → worklet (for sending)
80
+ const source = this.audioContext.createMediaStreamSource(this.mediaStream);
81
+ source.connect(this._input);
82
+ source.connect(this.workletNode);
83
+
84
+ // Create output analyser for visualization
85
+ this._output = this.audioContext.createAnalyser();
86
+ this._output.fftSize = 256;
87
+ this._output.smoothingTimeConstant = 0.8;
88
+ this._output.connect(this.audioContext.destination);
89
+ }
90
+
91
+ /**
92
+ * Analyser node for speaker output (model audio).
93
+ */
94
+ get output(): AnalyserNode | null {
95
+ return this._output;
96
+ }
97
+
98
+ /**
99
+ * Analyser node for mic input (user audio).
100
+ */
101
+ get input(): AnalyserNode | null {
102
+ return this._input;
103
+ }
104
+
105
+ /**
106
+ * Send audio to be played through speakers.
107
+ * Audio is in wire format (24kHz PCM16), Web Audio resamples to device rate.
108
+ */
109
+ sendAudio(audio: string): void {
110
+ if (!this.audioContext || !this._output) return;
111
+
112
+ const pcm16 = base64ToPcm16.encode(audio);
113
+ const float32 = pcm16ToFloat32.encode(pcm16);
114
+
115
+ // Create buffer at wire format rate - Web Audio resamples automatically
116
+ const buffer = this.audioContext.createBuffer(
117
+ 1,
118
+ float32.length,
119
+ WIRE_FORMAT_SAMPLE_RATE,
120
+ );
121
+ buffer.getChannelData(0).set(float32);
122
+
123
+ const source = this.audioContext.createBufferSource();
124
+ source.buffer = buffer;
125
+ // Route through analyser for visualization (analyser → destination)
126
+ source.connect(this._output);
127
+
128
+ // Track source for interruption
129
+ this.activeSources.push(source);
130
+ source.onended = () => {
131
+ const idx = this.activeSources.indexOf(source);
132
+ if (idx !== -1) this.activeSources.splice(idx, 1);
133
+ };
134
+
135
+ // Schedule playback with lookahead to prevent gaps from network jitter
136
+ const now = this.audioContext.currentTime;
137
+ const minStartTime = now + PLAYBACK_LOOKAHEAD;
138
+ const startTime = Math.max(minStartTime, this.nextPlayTime);
139
+ source.start(startTime);
140
+ this.nextPlayTime = startTime + buffer.duration;
141
+ }
142
+
143
+ /**
144
+ * Interrupt audio playback.
145
+ */
146
+ interrupt(): void {
147
+ for (const source of this.activeSources) {
148
+ try {
149
+ source.stop();
150
+ } catch {
151
+ // Already stopped
152
+ }
153
+ }
154
+ this.activeSources = [];
155
+ this.nextPlayTime = 0;
156
+ }
157
+
158
+ /**
159
+ * Clean up resources.
160
+ */
161
+ close(): void {
162
+ this.interrupt();
163
+ this.workletNode?.disconnect();
164
+ this.workletNode = null;
165
+ this._output?.disconnect();
166
+ this._output = null;
167
+ this._input?.disconnect();
168
+ this._input = null;
169
+ if (this.workletUrl) {
170
+ URL.revokeObjectURL(this.workletUrl);
171
+ this.workletUrl = null;
172
+ }
173
+ this.mediaStream?.getTracks().forEach((track) => track.stop());
174
+ this.mediaStream = null;
175
+ this.audioContext?.close();
176
+ this.audioContext = null;
177
+ }
178
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "baseUrl": ".",
7
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
8
+ "jsx": "react-jsx",
9
+ "paths": {
10
+ "@/*": ["./src/*"]
11
+ }
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }