redmine-context 1.0.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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Extrator de ÁUDIO que encadeia conversão → transcrição (M4-14, #73, ADR-002).
3
+ *
4
+ * Fecha a lacuna do gap analysis: o whisper.cpp (#61) só consome WAV PCM 16 kHz
5
+ * mono, mas os anexos de áudio chegam crus (MP3/M4A/OGG/WAV arbitrário). Este
6
+ * extrator implementa o contrato {@link Extractor} e orquestra, num único job:
7
+ *
8
+ * arquivo de áudio cru → {@link convertAudioToWav} (#60) → {@link WhisperExtractor} (#61)
9
+ *
10
+ * Registrá-lo no registry default (em vez do whisper direto — MINOR-2 do QA) faz o
11
+ * `dispatchExtraction` rotear áudio automaticamente por magic bytes, produzindo a
12
+ * transcrição no caminho REAL (`extractIssueAttachments`), não mais `unsupported`.
13
+ *
14
+ * DECISÕES (ADR-002/ADR-004):
15
+ * - CONVERSÃO SEMPRE: mesmo um `.wav` é reprocessado pelo ffmpeg para GARANTIR 16
16
+ * kHz mono PCM (um WAV 44,1 kHz estéreo transcreveria errado). É a mesma política
17
+ * do pipeline de vídeo ({@link convertVideoToWav}); o ganho de pular a conversão
18
+ * de um WAV já-16k não compensa o risco de silenciosamente transcrever mal.
19
+ * - CACHE (ADR-004): `version`/`model`/`params` são DELEGADOS ao transcritor whisper
20
+ * — a chave attachment-level reflete o modelo GGUF e o idioma, então trocar o
21
+ * modelo reprocessa e o mesmo modelo é cache-hit (mesma invariante da #72).
22
+ * - DEGRADAÇÃO GRACIOSA: ffmpeg/whisper/modelo ausente ou timeout → `failed` com
23
+ * motivo, NUNCA lança (o bundle segue). O WAV intermediário é sempre limpo.
24
+ * - SEGURANÇA: reusa a invocação segura da #60/#61 (execFile sem shell,
25
+ * `-protocol_whitelist file`, env sanitizado, watchdog) — nada de spawn novo aqui.
26
+ */
27
+ import type { ExtractorParams } from '../cache/contract.js';
28
+ import type { ExtractionResult } from '../contract.js';
29
+ import { type ConvertAudioToWavOptions, type WavConversionResult } from './audio.js';
30
+ import type { ExtractOptions, Extractor } from './dispatcher.js';
31
+ /**
32
+ * MIMEs REAIS de áudio roteados para este extrator. Reusa a lista do whisper (#61)
33
+ * para cobertura idêntica à que o whisper cobria quando registrado diretamente.
34
+ */
35
+ export declare const AUDIO_MIMES: readonly string[];
36
+ /** Opções de construção do {@link AudioExtractor}. */
37
+ export interface AudioExtractorOptions {
38
+ /**
39
+ * Extrator whisper (#61) que transcreve o WAV. `version`/`model`/`params` dele
40
+ * definem a identidade de cache deste extrator (ADR-004).
41
+ */
42
+ readonly transcriber: Extractor;
43
+ /**
44
+ * Conversão áudio → WAV; default: {@link convertAudioToWav} (com o `logger`
45
+ * repassado). Injetável para testes herméticos sem ffmpeg real.
46
+ */
47
+ readonly convert?: (inputPath: string) => Promise<WavConversionResult>;
48
+ /** Opções repassadas à conversão default (binário, timeouts, tempDir). */
49
+ readonly convertOptions?: ConvertAudioToWavOptions;
50
+ /** Remove o WAV intermediário após a transcrição (best-effort); default `fs.rm` com `force`. */
51
+ readonly rm?: (path: string) => Promise<void>;
52
+ }
53
+ /**
54
+ * Extrator de áudio (ADR-002) que converte o anexo para WAV 16 kHz mono e o
55
+ * transcreve via whisper.cpp. Implementa {@link Extractor} e expõe `version`/
56
+ * `model`/`params` do transcritor para {@link buildAttachmentKey} (ADR-004).
57
+ */
58
+ export declare class AudioExtractor implements Extractor {
59
+ readonly id = "audio-transcribe";
60
+ readonly version: string;
61
+ readonly supportedMimes: readonly string[];
62
+ /** Modelo lógico da chave de cache — herdado do whisper (GGUF), fallback no id. */
63
+ readonly model: string;
64
+ /** Parâmetros da chave de cache — herdados do whisper (ex.: `{ language }`). */
65
+ readonly params: ExtractorParams;
66
+ private readonly transcriber;
67
+ private readonly convert;
68
+ private readonly convertOptions;
69
+ private readonly rm;
70
+ /**
71
+ * @param options - Ver {@link AudioExtractorOptions}.
72
+ */
73
+ constructor(options: AudioExtractorOptions);
74
+ /**
75
+ * Transcreve o áudio em `filePath`: converte para WAV 16 kHz mono e passa ao
76
+ * whisper. Nunca lança — falha de conversão (ffmpeg ausente/timeout) ou de
77
+ * transcrição vira `{ status: 'failed', reason }` (ADR-002). O WAV intermediário
78
+ * é sempre removido (best-effort).
79
+ *
80
+ * @param filePath - Caminho absoluto do áudio baixado no cache.
81
+ * @param options - MIME real + logger — ver {@link ExtractOptions}.
82
+ * @returns `done` com `text` no sucesso; `failed` com motivo claro na falha.
83
+ */
84
+ extract(filePath: string, options: ExtractOptions): Promise<ExtractionResult>;
85
+ /**
86
+ * Roda a conversão áudio → WAV (injetável nos testes; default: ffmpeg real da #60).
87
+ *
88
+ * @param filePath - Caminho do áudio cru.
89
+ * @param logger - Logger repassado à conversão default.
90
+ * @param signal - Sinal de cancelamento (#69/#73) fiado ao ffmpeg da conversão default.
91
+ * @returns Resultado da conversão (`done` com `wavPath` ou `failed` com motivo).
92
+ */
93
+ private runConvert;
94
+ }
95
+ /**
96
+ * Cria um {@link AudioExtractor} a partir de um transcritor whisper. Fino wrapper
97
+ * de composição — ponto de entrada consumido por {@link createDefaultRegistry}.
98
+ *
99
+ * @param options - Transcritor + deps injetáveis — ver {@link AudioExtractorOptions}.
100
+ * @returns O extrator pronto para registro no {@link ExtractorRegistry}.
101
+ * @example
102
+ * const whisper = await createWhisperExtractor();
103
+ * const audio = createAudioExtractor({ transcriber: whisper });
104
+ */
105
+ export declare function createAudioExtractor(options: AudioExtractorOptions): AudioExtractor;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Extrator de ÁUDIO que encadeia conversão → transcrição (M4-14, #73, ADR-002).
3
+ *
4
+ * Fecha a lacuna do gap analysis: o whisper.cpp (#61) só consome WAV PCM 16 kHz
5
+ * mono, mas os anexos de áudio chegam crus (MP3/M4A/OGG/WAV arbitrário). Este
6
+ * extrator implementa o contrato {@link Extractor} e orquestra, num único job:
7
+ *
8
+ * arquivo de áudio cru → {@link convertAudioToWav} (#60) → {@link WhisperExtractor} (#61)
9
+ *
10
+ * Registrá-lo no registry default (em vez do whisper direto — MINOR-2 do QA) faz o
11
+ * `dispatchExtraction` rotear áudio automaticamente por magic bytes, produzindo a
12
+ * transcrição no caminho REAL (`extractIssueAttachments`), não mais `unsupported`.
13
+ *
14
+ * DECISÕES (ADR-002/ADR-004):
15
+ * - CONVERSÃO SEMPRE: mesmo um `.wav` é reprocessado pelo ffmpeg para GARANTIR 16
16
+ * kHz mono PCM (um WAV 44,1 kHz estéreo transcreveria errado). É a mesma política
17
+ * do pipeline de vídeo ({@link convertVideoToWav}); o ganho de pular a conversão
18
+ * de um WAV já-16k não compensa o risco de silenciosamente transcrever mal.
19
+ * - CACHE (ADR-004): `version`/`model`/`params` são DELEGADOS ao transcritor whisper
20
+ * — a chave attachment-level reflete o modelo GGUF e o idioma, então trocar o
21
+ * modelo reprocessa e o mesmo modelo é cache-hit (mesma invariante da #72).
22
+ * - DEGRADAÇÃO GRACIOSA: ffmpeg/whisper/modelo ausente ou timeout → `failed` com
23
+ * motivo, NUNCA lança (o bundle segue). O WAV intermediário é sempre limpo.
24
+ * - SEGURANÇA: reusa a invocação segura da #60/#61 (execFile sem shell,
25
+ * `-protocol_whitelist file`, env sanitizado, watchdog) — nada de spawn novo aqui.
26
+ */
27
+ import { rm as fsRm } from 'node:fs/promises';
28
+ import { convertAudioToWav, } from './audio.js';
29
+ import { WHISPER_MIMES } from './whisper-extract.js';
30
+ /** Identificador estável do extrator (entra em metadados/cache-key como fallback). */
31
+ const EXTRACTOR_ID = 'audio-transcribe';
32
+ /**
33
+ * MIMEs REAIS de áudio roteados para este extrator. Reusa a lista do whisper (#61)
34
+ * para cobertura idêntica à que o whisper cobria quando registrado diretamente.
35
+ */
36
+ export const AUDIO_MIMES = WHISPER_MIMES;
37
+ /**
38
+ * Extrator de áudio (ADR-002) que converte o anexo para WAV 16 kHz mono e o
39
+ * transcreve via whisper.cpp. Implementa {@link Extractor} e expõe `version`/
40
+ * `model`/`params` do transcritor para {@link buildAttachmentKey} (ADR-004).
41
+ */
42
+ export class AudioExtractor {
43
+ id = EXTRACTOR_ID;
44
+ version;
45
+ supportedMimes = AUDIO_MIMES;
46
+ /** Modelo lógico da chave de cache — herdado do whisper (GGUF), fallback no id. */
47
+ model;
48
+ /** Parâmetros da chave de cache — herdados do whisper (ex.: `{ language }`). */
49
+ params;
50
+ transcriber;
51
+ convert;
52
+ convertOptions;
53
+ rm;
54
+ /**
55
+ * @param options - Ver {@link AudioExtractorOptions}.
56
+ */
57
+ constructor(options) {
58
+ this.transcriber = options.transcriber;
59
+ this.version = options.transcriber.version;
60
+ // Reason (ADR-004): a identidade de cache deste extrator É a do whisper — trocar
61
+ // o GGUF/idioma reprocessa; sem model/params no transcritor, cai no id estável.
62
+ this.model = options.transcriber.model ?? EXTRACTOR_ID;
63
+ this.params = options.transcriber.params ?? {};
64
+ this.convert = options.convert;
65
+ this.convertOptions = options.convertOptions;
66
+ this.rm = options.rm ?? ((path) => fsRm(path, { force: true }));
67
+ }
68
+ /**
69
+ * Transcreve o áudio em `filePath`: converte para WAV 16 kHz mono e passa ao
70
+ * whisper. Nunca lança — falha de conversão (ffmpeg ausente/timeout) ou de
71
+ * transcrição vira `{ status: 'failed', reason }` (ADR-002). O WAV intermediário
72
+ * é sempre removido (best-effort).
73
+ *
74
+ * @param filePath - Caminho absoluto do áudio baixado no cache.
75
+ * @param options - MIME real + logger — ver {@link ExtractOptions}.
76
+ * @returns `done` com `text` no sucesso; `failed` com motivo claro na falha.
77
+ */
78
+ async extract(filePath, options) {
79
+ const conversion = await this.runConvert(filePath, options.logger, options.signal);
80
+ if (conversion.status === 'failed') {
81
+ return conversionFailure(this.id, options.mime, conversion);
82
+ }
83
+ const wavPath = conversion.wavPath;
84
+ try {
85
+ // `options` (com o `signal` de #69/#73) é repassado ao transcritor: o abort
86
+ // alcança o whisper e MATA o subprocesso via `runWithWatchdog`.
87
+ return await this.transcriber.extract(wavPath, options);
88
+ }
89
+ catch (error) {
90
+ // Reason: o whisper é gracioso por contrato, mas um transcritor injetado
91
+ // hostil/quebrado não pode derrubar o bundle — degrada para failed (ADR-002).
92
+ return {
93
+ status: 'failed',
94
+ mime: options.mime,
95
+ metadata: {
96
+ extractorId: this.id,
97
+ reason: 'erro-transcricao',
98
+ error: error instanceof Error ? error.message : String(error),
99
+ },
100
+ };
101
+ }
102
+ finally {
103
+ // O WAV é um artefato intermediário do cache temp; descarta em qualquer
104
+ // desfecho para não acumular lixo (convenção de `audio.ts`/`video.ts`).
105
+ await this.rm(wavPath).catch(() => undefined);
106
+ }
107
+ }
108
+ /**
109
+ * Roda a conversão áudio → WAV (injetável nos testes; default: ffmpeg real da #60).
110
+ *
111
+ * @param filePath - Caminho do áudio cru.
112
+ * @param logger - Logger repassado à conversão default.
113
+ * @param signal - Sinal de cancelamento (#69/#73) fiado ao ffmpeg da conversão default.
114
+ * @returns Resultado da conversão (`done` com `wavPath` ou `failed` com motivo).
115
+ */
116
+ runConvert(filePath, logger, signal) {
117
+ if (this.convert !== undefined) {
118
+ return this.convert(filePath);
119
+ }
120
+ return convertAudioToWav(filePath, {
121
+ ...(this.convertOptions ?? {}),
122
+ ...(logger !== undefined ? { logger } : {}),
123
+ ...(signal !== undefined ? { signal } : {}),
124
+ });
125
+ }
126
+ }
127
+ /**
128
+ * Monta um {@link ExtractionResult} `failed` a partir de uma falha de conversão,
129
+ * sem injetar chaves `undefined` (respeita `exactOptionalPropertyTypes`).
130
+ *
131
+ * @param extractorId - Id do extrator que reporta a falha.
132
+ * @param mime - MIME real do áudio.
133
+ * @param failure - Falha devolvida pela conversão áudio → WAV.
134
+ * @returns Resultado `failed` com `reason`/`error`/`hint` preservados.
135
+ */
136
+ function conversionFailure(extractorId, mime, failure) {
137
+ const metadata = { extractorId, reason: failure.reason };
138
+ if (failure.error !== undefined)
139
+ metadata.error = failure.error;
140
+ if (failure.hint !== undefined)
141
+ metadata.hint = failure.hint;
142
+ return { status: 'failed', mime, metadata };
143
+ }
144
+ /**
145
+ * Cria um {@link AudioExtractor} a partir de um transcritor whisper. Fino wrapper
146
+ * de composição — ponto de entrada consumido por {@link createDefaultRegistry}.
147
+ *
148
+ * @param options - Transcritor + deps injetáveis — ver {@link AudioExtractorOptions}.
149
+ * @returns O extrator pronto para registro no {@link ExtractorRegistry}.
150
+ * @example
151
+ * const whisper = await createWhisperExtractor();
152
+ * const audio = createAudioExtractor({ transcriber: whisper });
153
+ */
154
+ export function createAudioExtractor(options) {
155
+ return new AudioExtractor(options);
156
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Conversão de áudio para WAV 16 kHz mono via `ffmpeg` (M4-04, #60, ADR-002).
3
+ *
4
+ * Prepara um anexo de áudio para a transcrição pelo whisper.cpp (#61, M4-05), que
5
+ * espera PCM 16-bit, 16 kHz, mono. Esta issue cobre SÓ a conversão áudio → WAV; a
6
+ * extração de faixa de áudio a partir de vídeo é a #63 (M4-07) e o extrator
7
+ * whisper é a #61 — nenhum dos dois é implementado aqui.
8
+ *
9
+ * DECISÕES DE SEGURANÇA/ROBUSTEZ (ADR-002), todas exercitadas por testes:
10
+ *
11
+ * - INVOCAÇÃO SEM SHELL: `execFile` (nunca `exec`/shell) com lista de argumentos
12
+ * explícita — o `inputPath` NUNCA é interpolado numa string de shell.
13
+ * - `-protocol_whitelist file`: o ffmpeg só pode abrir o arquivo local dado, nunca
14
+ * protocolos remotos embutidos em playlists/manifests hostis (asserção explícita
15
+ * nos args pelo teste).
16
+ * - ENV SANITIZADO: o subprocesso recebe um env MÍNIMO (só `PATH`) — segredos do
17
+ * processo pai (ex.: `REDMINE_API_KEY`) NUNCA vazam para o ffmpeg.
18
+ * - TIMEOUT + KILL: watchdog envia `SIGTERM` no timeout e, após a graça, `SIGKILL`
19
+ * — um ffmpeg travado não pendura a fila de jobs.
20
+ * - DEGRADAÇÃO GRACIOSA: binário ausente, exit != 0 ou timeout NÃO lançam; devolvem
21
+ * {@link WavConversionResult} com `status: 'failed'` + motivo legível, sem quebrar
22
+ * o bundle.
23
+ *
24
+ * TESTABILIDADE: o executor do subprocesso, o localizador do ffmpeg, o dir temp e o
25
+ * `mkdir` são INJETÁVEIS — os testes unitários são herméticos (sem ffmpeg/FS reais)
26
+ * e asseguram os args passados ao executor.
27
+ */
28
+ import type { Logger } from '../client/index.js';
29
+ import type { ExtractionStatus } from '../contract.js';
30
+ /**
31
+ * Diretório canônico do cache onde os WAVs temporários são gravados — `env-paths`
32
+ * cache do usuário (por SO) + `/tmp`. Mesmo mecanismo dos demais módulos de mídia
33
+ * ({@link whisperModelDir}).
34
+ *
35
+ * @returns Caminho absoluto do dir temp (ex.: `~/Library/Caches/redmine-context/tmp`).
36
+ * @example
37
+ * const dir = audioTempDir();
38
+ */
39
+ export declare function audioTempDir(): string;
40
+ /** Invocação concreta do ffmpeg passada ao {@link FfmpegRunner}. */
41
+ export interface FfmpegInvocation {
42
+ /** Caminho absoluto do binário `ffmpeg` já resolvido. */
43
+ readonly bin: string;
44
+ /** Argumentos do ffmpeg (sem shell), incluindo `-protocol_whitelist file`. */
45
+ readonly args: readonly string[];
46
+ /** Env SANITIZADO do subprocesso (só `PATH`). */
47
+ readonly env: NodeJS.ProcessEnv;
48
+ /** Timeout antes do `SIGTERM` (ms). */
49
+ readonly timeoutMs: number;
50
+ /** Graça `SIGTERM` → `SIGKILL` (ms). */
51
+ readonly killGraceMs: number;
52
+ /**
53
+ * Sinal de CANCELAMENTO (#69/#73) repassado ao {@link runWithWatchdog}, que MATA
54
+ * o ffmpeg (`SIGTERM`→`SIGKILL`) ao abortar. Opcional/aditivo (respeita
55
+ * `exactOptionalPropertyTypes` — nunca injetado como `undefined`).
56
+ */
57
+ readonly signal?: AbortSignal;
58
+ }
59
+ /**
60
+ * Executor injetável do subprocesso ffmpeg. Resolve no sucesso (exit 0); rejeita
61
+ * em falha (exit != 0, binário não-executável em runtime) — rejeitando com um erro
62
+ * de nome `FfmpegTimeoutError` para sinalizar estouro de timeout.
63
+ */
64
+ export type FfmpegRunner = (invocation: FfmpegInvocation) => Promise<void>;
65
+ /** Sucesso da conversão: caminho do WAV 16 kHz mono gravado no dir temp. */
66
+ export interface WavConversionSuccess {
67
+ readonly status: Extract<ExtractionStatus, 'done'>;
68
+ /** Caminho absoluto do WAV produzido no dir temp do cache. */
69
+ readonly wavPath: string;
70
+ }
71
+ /** Falha graciosa da conversão (ADR-002) — nunca lança, sempre traz um motivo. */
72
+ export interface WavConversionFailure {
73
+ readonly status: Extract<ExtractionStatus, 'failed'>;
74
+ /** Motivo canônico da falha (`ffmpeg-nao-instalado` | `erro-conversao` | `timeout`). */
75
+ readonly reason: string;
76
+ /** Mensagem de erro subjacente, quando houver. */
77
+ readonly error?: string;
78
+ /** Dica de remediação (ex.: como instalar o ffmpeg). */
79
+ readonly hint?: string;
80
+ }
81
+ /**
82
+ * Resultado tipado da conversão áudio → WAV. Reutiliza o vocabulário de status do
83
+ * core ({@link ExtractionStatus}) — sem status paralelo.
84
+ */
85
+ export type WavConversionResult = WavConversionSuccess | WavConversionFailure;
86
+ /** Opções (todas injetáveis para testes herméticos) de {@link convertAudioToWav}. */
87
+ export interface ConvertAudioToWavOptions {
88
+ /**
89
+ * Localizador do binário `ffmpeg`; retorna o caminho absoluto ou `undefined`
90
+ * (não instalado). Default: {@link findFfmpeg} no ambiente real.
91
+ */
92
+ readonly findFfmpegBinary?: () => string | undefined;
93
+ /** Executor do subprocesso; default: watchdog real com `execFile`. */
94
+ readonly run?: FfmpegRunner;
95
+ /** Dir temp de saída; default: {@link audioTempDir}. */
96
+ readonly tempDir?: string;
97
+ /** Criador de diretório (recursivo); default: `fs/promises.mkdir`. */
98
+ readonly mkdir?: (dir: string) => Promise<void>;
99
+ /** Remove o `.wav` parcial na falha (best-effort); default: `fs/promises.rm` com `force`. */
100
+ readonly rm?: (path: string) => Promise<void>;
101
+ /** Timeout antes do `SIGTERM` (ms); default {@link DEFAULT_TIMEOUT_MS}. */
102
+ readonly timeoutMs?: number;
103
+ /** Graça `SIGTERM` → `SIGKILL` (ms); default {@link DEFAULT_KILL_GRACE_MS}. */
104
+ readonly killGraceMs?: number;
105
+ /**
106
+ * Sinal de CANCELAMENTO (#69/#73) repassado à invocação/`runWithWatchdog`, que
107
+ * MATA o ffmpeg ao abortar. Opcional/aditivo (respeita `exactOptionalPropertyTypes`).
108
+ */
109
+ readonly signal?: AbortSignal;
110
+ /** Logger para o aviso de binário ausente; sem default de lib (ADR-003). */
111
+ readonly logger?: Logger;
112
+ }
113
+ /**
114
+ * Converte um arquivo de áudio para WAV PCM 16-bit, 16 kHz, mono no dir temp do
115
+ * cache — o formato que o whisper.cpp (#61) consome. NUNCA lança: binário ausente,
116
+ * exit != 0 ou timeout viram `{ status: 'failed', reason }` (degradação graciosa,
117
+ * ADR-002), preservando o bundle.
118
+ *
119
+ * @param inputPath - Caminho absoluto do áudio de entrada já baixado no cache.
120
+ * @param options - Deps injetáveis + timeouts + logger — ver {@link ConvertAudioToWavOptions}.
121
+ * @returns `done` com `wavPath` no sucesso; `failed` com motivo claro na falha.
122
+ * @example
123
+ * const result = await convertAudioToWav('/cache/att/original.mp3');
124
+ * if (result.status === 'done') transcribe(result.wavPath);
125
+ */
126
+ export declare function convertAudioToWav(inputPath: string, options?: ConvertAudioToWavOptions): Promise<WavConversionResult>;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Conversão de áudio para WAV 16 kHz mono via `ffmpeg` (M4-04, #60, ADR-002).
3
+ *
4
+ * Prepara um anexo de áudio para a transcrição pelo whisper.cpp (#61, M4-05), que
5
+ * espera PCM 16-bit, 16 kHz, mono. Esta issue cobre SÓ a conversão áudio → WAV; a
6
+ * extração de faixa de áudio a partir de vídeo é a #63 (M4-07) e o extrator
7
+ * whisper é a #61 — nenhum dos dois é implementado aqui.
8
+ *
9
+ * DECISÕES DE SEGURANÇA/ROBUSTEZ (ADR-002), todas exercitadas por testes:
10
+ *
11
+ * - INVOCAÇÃO SEM SHELL: `execFile` (nunca `exec`/shell) com lista de argumentos
12
+ * explícita — o `inputPath` NUNCA é interpolado numa string de shell.
13
+ * - `-protocol_whitelist file`: o ffmpeg só pode abrir o arquivo local dado, nunca
14
+ * protocolos remotos embutidos em playlists/manifests hostis (asserção explícita
15
+ * nos args pelo teste).
16
+ * - ENV SANITIZADO: o subprocesso recebe um env MÍNIMO (só `PATH`) — segredos do
17
+ * processo pai (ex.: `REDMINE_API_KEY`) NUNCA vazam para o ffmpeg.
18
+ * - TIMEOUT + KILL: watchdog envia `SIGTERM` no timeout e, após a graça, `SIGKILL`
19
+ * — um ffmpeg travado não pendura a fila de jobs.
20
+ * - DEGRADAÇÃO GRACIOSA: binário ausente, exit != 0 ou timeout NÃO lançam; devolvem
21
+ * {@link WavConversionResult} com `status: 'failed'` + motivo legível, sem quebrar
22
+ * o bundle.
23
+ *
24
+ * TESTABILIDADE: o executor do subprocesso, o localizador do ffmpeg, o dir temp e o
25
+ * `mkdir` são INJETÁVEIS — os testes unitários são herméticos (sem ffmpeg/FS reais)
26
+ * e asseguram os args passados ao executor.
27
+ */
28
+ import { randomUUID } from 'node:crypto';
29
+ import { mkdir as fsMkdir, rm as fsRm } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+ import envPaths from 'env-paths';
32
+ import { findFfmpeg } from './ffmpeg.js';
33
+ import { runWithWatchdog, sanitizedEnv } from './subprocess.js';
34
+ /** Nome da aplicação para `env-paths` — em sincronia com o cache de anexos (ADR-004). */
35
+ const APP_NAME = 'redmine-context';
36
+ /** Subdiretório TEMP do cache onde os WAVs transcodificados são gravados. */
37
+ const TEMP_SUBDIR = 'tmp';
38
+ /** Taxa de amostragem alvo (Hz) — o whisper.cpp opera a 16 kHz. */
39
+ const TARGET_SAMPLE_RATE = '16000';
40
+ /** Número de canais alvo — mono (downmix de estéreo quando necessário). */
41
+ const TARGET_CHANNELS = '1';
42
+ /** Timeout default de uma conversão antes do `SIGTERM` (ms). */
43
+ const DEFAULT_TIMEOUT_MS = 60_000;
44
+ /** Graça entre `SIGTERM` e `SIGKILL` (ms) — dá ao ffmpeg chance de sair limpo. */
45
+ const DEFAULT_KILL_GRACE_MS = 2_000;
46
+ /** Teto do stdout/stderr capturado (8 MiB) — o ffmpeg é conciso em `-loglevel error`. */
47
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
48
+ /**
49
+ * Diretório canônico do cache onde os WAVs temporários são gravados — `env-paths`
50
+ * cache do usuário (por SO) + `/tmp`. Mesmo mecanismo dos demais módulos de mídia
51
+ * ({@link whisperModelDir}).
52
+ *
53
+ * @returns Caminho absoluto do dir temp (ex.: `~/Library/Caches/redmine-context/tmp`).
54
+ * @example
55
+ * const dir = audioTempDir();
56
+ */
57
+ export function audioTempDir() {
58
+ return join(envPaths(APP_NAME).cache, TEMP_SUBDIR);
59
+ }
60
+ /** Erro interno: o watchdog matou o ffmpeg por estourar o timeout. */
61
+ class FfmpegTimeoutError extends Error {
62
+ timeoutMs;
63
+ constructor(timeoutMs) {
64
+ super(`ffmpeg excedeu o timeout de ${timeoutMs}ms e foi encerrado`);
65
+ this.timeoutMs = timeoutMs;
66
+ this.name = 'FfmpegTimeoutError';
67
+ }
68
+ }
69
+ /**
70
+ * Monta os argumentos do ffmpeg para transcodificar `inputPath` → WAV PCM 16-bit,
71
+ * 16 kHz, mono em `outputPath`. `-protocol_whitelist file` (opção de INPUT) precede
72
+ * o `-i`; as opções de saída (`-ar`/`-ac`/`-c:a`/`-f`) vêm depois.
73
+ *
74
+ * @param inputPath - Caminho absoluto do áudio de entrada.
75
+ * @param outputPath - Caminho absoluto do WAV de saída.
76
+ * @returns Lista de argumentos, na ordem exigida pelo ffmpeg.
77
+ */
78
+ function buildFfmpegArgs(inputPath, outputPath) {
79
+ return [
80
+ '-nostdin', // não consome stdin (execução headless)
81
+ '-loglevel',
82
+ 'error', // silencioso, exceto erros
83
+ '-protocol_whitelist',
84
+ 'file', // ADR-002: só o arquivo local dado, nada remoto
85
+ '-i',
86
+ inputPath,
87
+ '-vn', // descarta qualquer faixa de vídeo do container
88
+ '-ar',
89
+ TARGET_SAMPLE_RATE,
90
+ '-ac',
91
+ TARGET_CHANNELS,
92
+ '-c:a',
93
+ 'pcm_s16le', // PCM 16-bit little-endian, o que o whisper.cpp lê
94
+ '-f',
95
+ 'wav',
96
+ '-y', // sobrescreve o destino (nome único, mas defensivo)
97
+ outputPath,
98
+ ];
99
+ }
100
+ /**
101
+ * Executor default: delega ao watchdog compartilhado ({@link runWithWatchdog}) —
102
+ * ffmpeg SEM shell, env sanitizado e escalonamento `SIGTERM` → graça → `SIGKILL`.
103
+ * O stdout é irrelevante para a conversão (o WAV é escrito em disco); só o exit
104
+ * importa. O estouro de timeout é sinalizado com {@link FfmpegTimeoutError} para
105
+ * preservar a classificação de falha (`reason: 'timeout'`).
106
+ *
107
+ * @param invocation - Ver {@link FfmpegInvocation}.
108
+ * @returns Promessa resolvida no sucesso do ffmpeg.
109
+ */
110
+ function defaultRun(invocation) {
111
+ const { bin, args, env, timeoutMs, killGraceMs, signal } = invocation;
112
+ return runWithWatchdog({
113
+ bin,
114
+ args,
115
+ env,
116
+ timeoutMs,
117
+ killGraceMs,
118
+ maxBuffer: MAX_BUFFER_BYTES,
119
+ ...(signal !== undefined ? { signal } : {}),
120
+ }, { makeTimeoutError: (ms) => new FfmpegTimeoutError(ms) }).then(() => undefined);
121
+ }
122
+ /** `true` se o erro sinaliza estouro de timeout do watchdog (por nome, robusto a DI). */
123
+ function isTimeoutError(error) {
124
+ return error instanceof Error && error.name === 'FfmpegTimeoutError';
125
+ }
126
+ /**
127
+ * Converte um arquivo de áudio para WAV PCM 16-bit, 16 kHz, mono no dir temp do
128
+ * cache — o formato que o whisper.cpp (#61) consome. NUNCA lança: binário ausente,
129
+ * exit != 0 ou timeout viram `{ status: 'failed', reason }` (degradação graciosa,
130
+ * ADR-002), preservando o bundle.
131
+ *
132
+ * @param inputPath - Caminho absoluto do áudio de entrada já baixado no cache.
133
+ * @param options - Deps injetáveis + timeouts + logger — ver {@link ConvertAudioToWavOptions}.
134
+ * @returns `done` com `wavPath` no sucesso; `failed` com motivo claro na falha.
135
+ * @example
136
+ * const result = await convertAudioToWav('/cache/att/original.mp3');
137
+ * if (result.status === 'done') transcribe(result.wavPath);
138
+ */
139
+ export async function convertAudioToWav(inputPath, options = {}) {
140
+ const findBinary = options.findFfmpegBinary ?? (() => findFfmpeg()?.path);
141
+ const bin = findBinary();
142
+ if (bin === undefined) {
143
+ options.logger?.warn('ffmpeg: binário não encontrado no PATH nem em locais convencionais; ' +
144
+ 'instale o ffmpeg (ex.: `brew install ffmpeg`) — veja o doctor');
145
+ return {
146
+ status: 'failed',
147
+ reason: 'ffmpeg-nao-instalado',
148
+ hint: 'instale o ffmpeg; o doctor (#57) valida a instalação e orienta por SO',
149
+ };
150
+ }
151
+ const run = options.run ?? defaultRun;
152
+ const tempDir = options.tempDir ?? audioTempDir();
153
+ const mkdir = options.mkdir ?? ((dir) => fsMkdir(dir, { recursive: true }).then(() => undefined));
154
+ const rm = options.rm ?? ((path) => fsRm(path, { force: true }));
155
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
156
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
157
+ const signal = options.signal;
158
+ // Nome único evita colisão entre conversões concorrentes no mesmo dir temp.
159
+ const outputPath = join(tempDir, `${randomUUID()}.wav`);
160
+ const args = buildFfmpegArgs(inputPath, outputPath);
161
+ try {
162
+ await mkdir(tempDir);
163
+ await run({
164
+ bin,
165
+ args,
166
+ env: sanitizedEnv(),
167
+ timeoutMs,
168
+ killGraceMs,
169
+ ...(signal !== undefined ? { signal } : {}),
170
+ });
171
+ return { status: 'done', wavPath: outputPath };
172
+ }
173
+ catch (error) {
174
+ // O ffmpeg com `-y` pode ter criado um WAV parcial/zerado; descarta-o para não
175
+ // acumular lixo no cache temp (convenção de `download.ts`). Best-effort.
176
+ await rm(outputPath).catch(() => undefined);
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ return {
179
+ status: 'failed',
180
+ reason: isTimeoutError(error) ? 'timeout' : 'erro-conversao',
181
+ error: message,
182
+ };
183
+ }
184
+ }