import { DestroyRef, assertInInjectionContext, inject, signal, } from '@angular/core' import { AudioRecorder } from '@tanstack/ai-client' import type { Signal } from '@angular/core' import type { AudioRecorderOptions, AudioRecording, InferAudioRecordingOutput, } from '@tanstack/ai-client' export type InjectAudioRecorderOptions = AudioRecorderOptions & { /** * Optional transform applied to the recording when `stop()` resolves. Its * (awaited) return value becomes `recording` and the resolved value of * `stop()`. Return nothing to keep the raw `AudioRecording`. */ onComplete?: TOnComplete } export interface InjectAudioRecorderResult { /** Reactive: latest recording (transformed if `onComplete` provided), or null. */ recording: Signal /** Reactive: true while actively capturing audio. */ isRecording: Signal /** Whether the browser supports recording. */ isSupported: boolean start: () => Promise /** Stop and resolve with the completed recording (transformed if `onComplete` provided). */ stop: () => Promise /** Discard the in-progress recording and release the mic. */ cancel: () => void } /** * Angular injectable for recording an audio message. The resolved recording * carries `.part` (for `injectChat`'s `sendMessage`) and `.base64` (for the * generation injectables). Must be called in an injection context. * * Errors are delivered via `onError`. `start()` and `stop()` also reject on * failure (and `stop()` rejects with `Recording cancelled` if `cancel()` runs * while a stop is in flight, e.g. on destroy) — handle one channel, not both. */ // The transforming overload requires `onComplete`. Without that constraint an // options object carrying only unrelated keys (`injectAudioRecorder({ onError })`) // still matches it, `TOnComplete` infers as `unknown`, and `recording`/`stop()` // collapse to `unknown` — so passing any option would silently cost you the // `AudioRecording` type. Requiring it here sends those calls to the second // overload instead (issue #1001). export function injectAudioRecorder< TOnComplete extends (recording: AudioRecording) => unknown, >( options: InjectAudioRecorderOptions & { onComplete: TOnComplete }, ): InjectAudioRecorderResult> export function injectAudioRecorder( options?: InjectAudioRecorderOptions, ): InjectAudioRecorderResult export function injectAudioRecorder( options: InjectAudioRecorderOptions< (recording: AudioRecording) => unknown > = {}, ): InjectAudioRecorderResult { assertInInjectionContext(injectAudioRecorder) const destroyRef = inject(DestroyRef) const recorder = new AudioRecorder({ ...(options.audio !== undefined && { audio: options.audio }), ...(options.mimeType !== undefined && { mimeType: options.mimeType }), ...(options.onError !== undefined && { onError: options.onError }), }) const isRecording = signal(false) const recording = signal(null) const unsubscribe = recorder.subscribe((state) => { isRecording.set(state === 'recording') }) destroyRef.onDestroy(() => { unsubscribe() recorder.cancel() }) const stop = async (): Promise => { const rawRecording = await recorder.stop() const transformed = await options.onComplete?.(rawRecording) // Only `undefined` (returning nothing) keeps the raw recording; a returned // null is a real value, matching the inferred output type. const output = transformed === undefined ? rawRecording : transformed recording.set(output) return output } return { recording: recording.asReadonly(), isRecording: isRecording.asReadonly(), isSupported: AudioRecorder.isSupported(), start: () => recorder.start(), stop, cancel: () => recorder.cancel(), } }