{"version":3,"file":"adapter.mjs","names":["#options","#getDefaultExecutor","#getDefaultFs","#getDefaultTmpdir","#getDefaultRandomName"],"sources":["../../../../src/batteries/tts/native/adapter.ts"],"sourcesContent":["/**\n * OS-native TTS (text-to-speech) adapter battery — shells out to the platform's own speech binary.\n *\n * @module @nhtio/adk/batteries/tts/native/adapter\n *\n * @remarks\n * **Node-only.** This adapter synthesizes by shelling out to the operating system's own speech\n * binary — macOS `say`, Linux `espeak-ng`, or Windows PowerShell `System.Speech` — and reading back\n * the WAV file it writes. It is model-less: it extends the shared {@link BaseTtsAdapterOptions}\n * (`voice`/`rate`) but adds NO `model` field. Every `node:*` import is a LAZY dynamic import inside a\n * method, so constructing and validating the adapter never touches node builtins and unit tests stay\n * hermetic (zero `child_process`, zero `fs`, zero real files).\n *\n * The shell-out and filesystem access are fully injectable: {@link NativeTtsAdapterOptions.executor}\n * (default: a lazy `node:child_process` `execFile` wrapper with an `AbortController` enforcing the\n * timeout), {@link NativeTtsAdapterOptions.fs} (default: `node:fs/promises`), plus\n * {@link NativeTtsAdapterOptions.tmpdir} / {@link NativeTtsAdapterOptions.randomName} seams for the\n * scratch output path. The flow mirrors the media domain's `soffice` engine — build args, exec,\n * read the output file, finally clean up — but this engine controls its own output path directly\n * (it does NOT depend on the media domain's `ScratchWorkspace`).\n *\n * Result classification is from the executor's FLAGS, never inferred from exit code or stderr (a\n * timeout, a signal kill, and a non-zero CLI exit are indistinguishable from those signals alone):\n * `result.timedOut` → `E_NATIVE_TTS_TIMEOUT` (504); else `result.failed` →\n * `E_NATIVE_TTS_ENGINE_ERROR` (502). The read-back WAV is hard-validated against the RIFF/WAVE\n * magic — a payload missing that magic throws `E_NATIVE_TTS_ENGINE_ERROR`.\n */\n\nimport { validateOptions } from './validation'\nimport { buildNativeTtsInvocation } from './helpers'\nimport { isError, isObject } from '@nhtio/adk/guards'\nimport {\n  E_NATIVE_TTS_ENGINE_ERROR,\n  E_NATIVE_TTS_TIMEOUT,\n  E_NATIVE_TTS_UNSUPPORTED_PLATFORM,\n} from './exceptions'\nimport type { GeneratedMediaOutput } from '../_shared'\nimport type {\n  NativeTtsAdapterOptions,\n  NativeSynthesizeOptions,\n  TtsBinaryExecutor,\n  TtsBinaryExecutorResult,\n  TtsFsLike,\n  NativeTtsPlatform,\n} from './types'\n\n/** The set of platforms this adapter can synthesize on. */\nconst SUPPORTED_PLATFORMS: ReadonlySet<NativeTtsPlatform> = new Set(['darwin', 'linux', 'win32'])\n\n/** Clamp `n` into the inclusive `[min, max]` band. */\nconst clamp = (n: number, min: number, max: number): number => Math.min(Math.max(n, min), max)\n\n/** Round to the nearest integer (half-up). */\nconst round = (n: number): number => Math.round(n)\n\n/**\n * TTS adapter that shells out to the OS's own speech binary. Reusable: construct once, call\n * {@link NativeTtsAdapter.synthesize} as many times as needed.\n *\n * @remarks\n * A zero-config `new NativeTtsAdapter()` is valid — it auto-detects the platform from\n * `process.platform` at `synthesize` time and resolves the default executor / fs / scratch-path\n * seams lazily.\n */\nexport class NativeTtsAdapter {\n  readonly #options: NativeTtsAdapterOptions\n\n  /**\n   * Whether this battery is available. `true` whenever a Node `process` is present on a supported\n   * {@link NativeTtsPlatform} — the engine itself is the platform's own binary, so there is no peer\n   * dependency to probe.\n   */\n  public static isAvailable(): boolean {\n    return (\n      typeof process !== 'undefined' &&\n      typeof process.platform === 'string' &&\n      SUPPORTED_PLATFORMS.has(process.platform as NativeTtsPlatform)\n    )\n  }\n\n  /**\n   * @param options - Constructor options. **All optional**; validated eagerly against\n   *   {@link @nhtio/adk/batteries/tts/native!nativeTtsOptionsSchema}. Pass `undefined` for zero-config.\n   * @throws {@link @nhtio/adk/batteries/tts/native!E_INVALID_NATIVE_TTS_OPTIONS} when invalid.\n   */\n  constructor(options: unknown = {}) {\n    this.#options = validateOptions(options ?? {})\n  }\n\n  /** Instance availability probe (honours an injected `isAvailable`). */\n  isAvailable(): boolean {\n    return (this.#options.isAvailable ?? NativeTtsAdapter.isAvailable)()\n  }\n\n  /** No-op. The native engine has nothing to preload — the binary is invoked fresh per call. */\n  async preload(): Promise<void> {\n    // intentionally empty: no cached model/session to warm.\n  }\n\n  /** No-op. The native engine holds no state between calls to reset. */\n  reset(): void {\n    // intentionally empty: no cached state to drop.\n  }\n\n  /**\n   * Resolve the default binary executor lazily: a `node:child_process` `execFile` wrapper that runs\n   * the invocation WITHOUT a shell (no interpolation) and aborts the child via an `AbortController`\n   * when `timeoutMs` elapses, setting `timedOut: true` on the result.\n   */\n  async #getDefaultExecutor(): Promise<TtsBinaryExecutor> {\n    const { execFile } = await import('node:child_process')\n    return {\n      exec(invocation): Promise<TtsBinaryExecutorResult> {\n        return new Promise((resolve) => {\n          const controller = new AbortController()\n          const timeoutMs = invocation.timeoutMs\n          let timer: ReturnType<typeof setTimeout> | undefined\n          if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) {\n            timer = setTimeout(() => controller.abort(), timeoutMs)\n          }\n          const stdoutChunks: Buffer[] = []\n          const stderrChunks: Buffer[] = []\n          let settled = false\n          const finish = (result: TtsBinaryExecutorResult): void => {\n            if (settled) return\n            settled = true\n            if (timer) clearTimeout(timer)\n            resolve(result)\n          }\n          const child = execFile(\n            invocation.cmd,\n            invocation.args,\n            { signal: controller.signal, maxBuffer: 1024 * 1024 * 64, windowsHide: true },\n            (err, stdout, stderr) => {\n              const timedOut = Boolean(controller.signal.aborted)\n              const failed = timedOut || err !== null\n              finish({\n                exitCode:\n                  isObject(err) && 'status' in err\n                    ? (err as { status?: number }).status\n                    : undefined,\n                stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''),\n                stderr: typeof stderr === 'string' ? stderr : String(stderr ?? ''),\n                failed,\n                timedOut,\n              })\n            }\n          )\n          child.stdout?.on('data', (c: Buffer) => stdoutChunks.push(c))\n          child.stderr?.on('data', (c: Buffer) => stderrChunks.push(c))\n          if (invocation.signal) {\n            if (invocation.signal.aborted) controller.abort()\n            else\n              invocation.signal.addEventListener('abort', () => controller.abort(), {\n                once: true,\n              })\n          }\n        })\n      },\n    }\n  }\n\n  /** Resolve the default filesystem seam lazily: `node:fs/promises`. */\n  async #getDefaultFs(): Promise<TtsFsLike> {\n    const fs = await import('node:fs/promises')\n    return {\n      readFile: (path: string) => fs.readFile(path),\n      unlink: (path: string) => fs.unlink(path),\n    }\n  }\n\n  /** Resolve the default scratch directory lazily: `node:os` `tmpdir()`. */\n  async #getDefaultTmpdir(): Promise<() => string> {\n    const os = await import('node:os')\n    return () => os.tmpdir()\n  }\n\n  /** Resolve the default scratch filename stem lazily: `crypto.randomUUID()`. */\n  async #getDefaultRandomName(): Promise<() => string> {\n    const crypto = await import('node:crypto')\n    return () => crypto.randomUUID()\n  }\n\n  /**\n   * Synthesizes text into a WAV audio clip.\n   *\n   * @remarks\n   * Resolves the platform (constructor `platform` or `process.platform`), the effective\n   * voice/rate/pitch (per-call overrides ctor), the words-per-minute (for `say`/`espeak-ng`) and\n   * the `-10..10` PowerShell rate (for win32), then shells out via the executor, reads the scratch\n   * WAV back, hard-validates the RIFF/WAVE magic, and unlinks the scratch file in a `finally`.\n   *\n   * @param text - The text to speak. Passed verbatim as the final positional arg / PowerShell literal.\n   * @param opts - Per-call options; each field overrides the constructor default of the same name.\n   * @returns A {@link GeneratedMediaOutput} descriptor with `kind: 'audio'`, `mimeType: 'audio/wav'`,\n   *   the WAV bytes, and `filename: 'speech.wav'`.\n   * @throws {@link @nhtio/adk/batteries/tts/native!E_NATIVE_TTS_UNSUPPORTED_PLATFORM} when the\n   *   resolved platform is not `darwin`/`linux`/`win32`.\n   * @throws {@link @nhtio/adk/batteries/tts/native!E_NATIVE_TTS_TIMEOUT} when the binary is aborted\n   *   for exceeding `timeoutMs` (default 60_000 ms).\n   * @throws {@link @nhtio/adk/batteries/tts/native!E_NATIVE_TTS_ENGINE_ERROR} when the binary fails,\n   *   produces no output, or yields bytes that are not a RIFF/WAVE file.\n   */\n  async synthesize(text: string, opts?: NativeSynthesizeOptions): Promise<GeneratedMediaOutput> {\n    const ctor = this.#options\n\n    // 1. Resolve platform. Auto-detect from process.platform when the ctor did not pin one.\n    let platform: NativeTtsPlatform\n    if (ctor.platform !== undefined) {\n      platform = ctor.platform\n    } else {\n      const detected =\n        typeof process !== 'undefined' && typeof process.platform === 'string'\n          ? process.platform\n          : ''\n      if (!SUPPORTED_PLATFORMS.has(detected as NativeTtsPlatform)) {\n        throw new E_NATIVE_TTS_UNSUPPORTED_PLATFORM([detected || 'unknown'])\n      }\n      platform = detected as NativeTtsPlatform\n    }\n\n    // 2. Resolve effective voice/rate/pitch (per-call overrides ctor).\n    const effectiveVoice = opts?.voice ?? ctor.voice\n    const effectiveRate = opts?.rate ?? ctor.rate\n    const effectivePitch = opts?.pitch ?? ctor.pitch\n\n    // wpm for say/espeak-ng; win32 rate int for PowerShell. Computed HERE (not in the helper).\n    const wpm = ctor.wordsPerMinute ?? clamp(round(175 * (effectiveRate ?? 1)), 80, 500)\n    const win32Rate = clamp(round(((effectiveRate ?? 1) - 1) * 10), -10, 10)\n\n    // 3. Mint the scratch outPath via the tmpdir/randomName seams.\n    const tmpdir = ctor.tmpdir ?? (await this.#getDefaultTmpdir())\n    const randomName = ctor.randomName ?? (await this.#getDefaultRandomName())\n    const { join } = await import('node:path')\n    const outPath = join(tmpdir(), `adk-tts-${randomName()}.wav`)\n\n    // 4. Build the invocation.\n    const invocation = buildNativeTtsInvocation({\n      platform,\n      outPath,\n      text,\n      command: ctor.command,\n      voice: effectiveVoice,\n      wordsPerMinute: wpm,\n      pitch: effectivePitch,\n      rate: win32Rate,\n      extraArgs: ctor.extraArgs,\n    })\n\n    // 5. Resolve the executor + fs seams BEFORE running anything, so that once the binary has\n    // (potentially) written the scratch file, EVERY exit path — including an executor rejection or a\n    // flag-based failure — reaches the `finally` unlink. Defaults are created lazily only when no\n    // seam was injected.\n    const executor: TtsBinaryExecutor = ctor.executor ?? (await this.#getDefaultExecutor())\n    const fs: TtsFsLike = ctor.fs ?? (await this.#getDefaultFs())\n    const timeoutMs = ctor.timeoutMs ?? 60_000\n\n    // 6. The scratch file is ALWAYS unlinked — on success, on executor rejection, on a flag-based\n    // failure, on a read failure, and on an invalid-WAV throw. The executor invocation is INSIDE the\n    // `try` so a rejecting executor (which may have left a partial file) still hits the cleanup.\n    try {\n      let result: TtsBinaryExecutorResult\n      try {\n        result = await executor.exec({ cmd: invocation.cmd, args: invocation.args, timeoutMs })\n      } catch (err) {\n        throw new E_NATIVE_TTS_ENGINE_ERROR([\n          `synthesis executor failed: ${isError(err) ? err.message : String(err)}`,\n        ])\n      }\n\n      // Classify from the FLAGS — never from exit code/stderr.\n      if (result.timedOut) {\n        throw new E_NATIVE_TTS_TIMEOUT([timeoutMs])\n      }\n      if (result.failed) {\n        throw new E_NATIVE_TTS_ENGINE_ERROR([result.stderr || 'synthesis failed'])\n      }\n\n      // 7. Read the bytes and hard-assert the RIFF/WAVE magic.\n      let bytes: Uint8Array\n      try {\n        bytes = await fs.readFile(outPath)\n      } catch (err) {\n        throw new E_NATIVE_TTS_ENGINE_ERROR([\n          `failed to read synthesized output: ${isError(err) ? err.message : String(err)}`,\n        ])\n      }\n      if (bytes.length < 12) {\n        throw new E_NATIVE_TTS_ENGINE_ERROR(['expected a RIFF/WAVE file'])\n      }\n      const ascii = (start: number, end: number): string =>\n        String.fromCharCode(...bytes.subarray(start, end))\n      const isRiff = ascii(0, 4) === 'RIFF'\n      const isWave = ascii(8, 12) === 'WAVE'\n      if (!isRiff || !isWave) {\n        throw new E_NATIVE_TTS_ENGINE_ERROR(['expected a RIFF/WAVE file'])\n      }\n      return {\n        kind: 'audio',\n        mimeType: 'audio/wav',\n        bytes,\n        filename: 'speech.wav',\n      }\n    } finally {\n      try {\n        await fs.unlink(outPath)\n      } catch {\n        // swallow — teardown must not throw\n      }\n    }\n  }\n}\n"],"mappings":"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"}