@runnerpro/backend 1.34.0 → 1.35.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 (28) hide show
  1. package/lib/cjs/index.js +8 -2
  2. package/lib/cjs/mediaProcessing/audio.js +169 -155
  3. package/lib/cjs/mediaProcessing/azureSpeech.js +114 -0
  4. package/lib/cjs/mediaProcessing/azureTranscribe.js +73 -0
  5. package/lib/cjs/mediaProcessing/correccionTranscripcion.js +313 -0
  6. package/lib/cjs/mediaProcessing/ffmpegUtils.js +41 -2
  7. package/lib/cjs/mediaProcessing/index.js +157 -32
  8. package/lib/cjs/mediaProcessing/video.js +4 -4
  9. package/lib/cjs/types/index.d.ts +3 -2
  10. package/lib/cjs/types/index.d.ts.map +1 -1
  11. package/lib/cjs/types/mediaProcessing/audio.d.ts +31 -36
  12. package/lib/cjs/types/mediaProcessing/audio.d.ts.map +1 -1
  13. package/lib/cjs/types/mediaProcessing/azureSpeech.d.ts +38 -0
  14. package/lib/cjs/types/mediaProcessing/azureSpeech.d.ts.map +1 -0
  15. package/lib/cjs/types/mediaProcessing/azureTranscribe.d.ts +26 -0
  16. package/lib/cjs/types/mediaProcessing/azureTranscribe.d.ts.map +1 -0
  17. package/lib/cjs/types/mediaProcessing/correccionTranscripcion.d.ts +49 -0
  18. package/lib/cjs/types/mediaProcessing/correccionTranscripcion.d.ts.map +1 -0
  19. package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts +20 -1
  20. package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts.map +1 -1
  21. package/lib/cjs/types/mediaProcessing/index.d.ts +42 -8
  22. package/lib/cjs/types/mediaProcessing/index.d.ts.map +1 -1
  23. package/lib/cjs/types/mediaProcessing/video.d.ts +2 -1
  24. package/lib/cjs/types/mediaProcessing/video.d.ts.map +1 -1
  25. package/lib/cjs/types/workout/saveWorkoutAplication.d.ts +22 -1
  26. package/lib/cjs/types/workout/saveWorkoutAplication.d.ts.map +1 -1
  27. package/lib/cjs/workout/saveWorkoutAplication.js +94 -2
  28. package/package.json +3 -3
package/lib/cjs/index.js CHANGED
@@ -24,8 +24,8 @@ var __importStar = (this && this.__importStar) || function (mod) {
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.saveDoneStructuraWorkout = exports.computeStreakStats = exports.getUnlockedAchievements = exports.evaluateAchievementsBounded = exports.evaluateAchievements = exports.saveWorkoutLaps = exports.saveWorkoutAplication = exports.getPlanificacionPrueba7dias = exports.sendWorkoutToWatch = exports.getDefaultWorkoutImage = exports.generateShareMap = exports.reduceSizeImage = exports.getLetter = exports.getNumberByLetter = exports.appendSheet = exports.writeSheet = exports.findCellByValue = exports.readSheet = exports.NOTION_DATABASES_ID = exports.notionEditPage = exports.notionAddPage = exports.notionGetDatabase = exports.notionGetUsers = exports.getCountNotificaciones = exports.chatExposed = exports.chatApi = exports.chat = exports.getExerciseTranslatedDescription = exports.useTranslation = exports.LANGUAGES = exports.translate = exports.CHANNEL_SLACK = exports.notifySlack = exports.fetchIA = exports.err = exports.sendMail = exports.pool = exports.toPgArray = exports.batchQuery = exports.longRunningQuery = exports.queryWithClient = exports.query = exports.sleep = exports.sendNotification = exports.TIPO_POR_DEFECTO = exports.urlPublica = exports.Archivo = exports.Contenedor = exports.StorageAzure = exports.achievementsCatalog = void 0;
27
- exports.BEDROCK_CLAUDE_SONNET_4 = exports.BEDROCK_CLAUDE_SONNET_4_5 = exports.BEDROCK_CLAUDE_OPUS_4_1 = exports.BEDROCK_CLAUDE_OPUS_4_5 = exports.BEDROCK_CLAUDE_SONNET = exports.BEDROCK_CLAUDE_OPUS = exports.FALLBACK_MODELS = exports.PRODUCTION_MODELS = exports.AZURE_FALLBACK_MODELS = exports.AZURE_PRIMARY_MODELS = exports.AZURE_DEEPSEEK_V4_FLASH = exports.AZURE_DEEPSEEK_V4_PRO = exports.AZURE_GPT_5_4_NANO = exports.AZURE_GPT_5_4_MINI = exports.AZURE_GPT_5_6_LUNA = exports.AZURE_GPT_5_6_TERRA = exports.AZURE_GPT_5_6_SOL = exports.AZURE_GPT_5_5 = exports.AZURE_DEEPSEEK_V4_FLASH_DEPLOYMENT = exports.AZURE_DEEPSEEK_V4_PRO_DEPLOYMENT = exports.AZURE_GPT_5_4_NANO_DEPLOYMENT = exports.AZURE_GPT_5_4_MINI_DEPLOYMENT = exports.AZURE_GPT_5_6_LUNA_DEPLOYMENT = exports.AZURE_GPT_5_6_TERRA_DEPLOYMENT = exports.AZURE_GPT_5_6_SOL_DEPLOYMENT = exports.AZURE_GPT_5_5_DEPLOYMENT = exports.AZURE_PREFIX = exports.GOOGLE_MODELS = exports.AI_MODELS = exports.MODEL_TIER = exports.runWithModels = exports.runWithCostTracking = exports.createModelFromString = exports.createAzureModelFromString = exports.createBedrockModelFromString = exports.createGoogleModelFromString = exports.createGoogleModel = exports.generateText = exports.generateObject = exports.reprocessRecentMedia = exports.processMediaFile = exports.analyzeFile = exports.describeTextFile = exports.describePdf = exports.describeVideo = exports.describeImage = exports.transcribeAudio = exports.updateSenderView = exports.saveResponseTime = exports.getZone = void 0;
28
- exports.calculateCost = exports.MODEL_PRICING = exports.AISTUDIO_PREFIX = exports.BEDROCK_CLAUDE_HAIKU = void 0;
27
+ exports.FALLBACK_MODELS = exports.PRODUCTION_MODELS = exports.AZURE_FALLBACK_MODELS = exports.AZURE_PRIMARY_MODELS = exports.AZURE_DEEPSEEK_V4_FLASH = exports.AZURE_DEEPSEEK_V4_PRO = exports.AZURE_GPT_5_4_NANO = exports.AZURE_GPT_5_4_MINI = exports.AZURE_GPT_5_6_LUNA = exports.AZURE_GPT_5_6_TERRA = exports.AZURE_GPT_5_6_SOL = exports.AZURE_GPT_5_5 = exports.AZURE_DEEPSEEK_V4_FLASH_DEPLOYMENT = exports.AZURE_DEEPSEEK_V4_PRO_DEPLOYMENT = exports.AZURE_GPT_5_4_NANO_DEPLOYMENT = exports.AZURE_GPT_5_4_MINI_DEPLOYMENT = exports.AZURE_GPT_5_6_LUNA_DEPLOYMENT = exports.AZURE_GPT_5_6_TERRA_DEPLOYMENT = exports.AZURE_GPT_5_6_SOL_DEPLOYMENT = exports.AZURE_GPT_5_5_DEPLOYMENT = exports.AZURE_PREFIX = exports.GOOGLE_MODELS = exports.AI_MODELS = exports.MODEL_TIER = exports.runWithModels = exports.runWithCostTracking = exports.createModelFromString = exports.createAzureModelFromString = exports.createBedrockModelFromString = exports.createGoogleModelFromString = exports.createGoogleModel = exports.generateText = exports.generateObject = exports.MARCADORES_AUDIO = exports.MARCADOR_NO_TRANSCRITO = exports.MARCADOR_SIN_VOZ = exports.GLOSARIO_RUNNING = exports.corregirTranscripcion = exports.reprocessMediaByIds = exports.reprocessRecentMedia = exports.processMediaFile = exports.analyzeFile = exports.describeTextFile = exports.describePdf = exports.describeVideo = exports.describeImage = exports.transcribeAudio = exports.updateSenderView = exports.saveResponseTime = exports.getZone = void 0;
28
+ exports.calculateCost = exports.MODEL_PRICING = exports.AISTUDIO_PREFIX = exports.BEDROCK_CLAUDE_HAIKU = exports.BEDROCK_CLAUDE_SONNET_4 = exports.BEDROCK_CLAUDE_SONNET_4_5 = exports.BEDROCK_CLAUDE_OPUS_4_1 = exports.BEDROCK_CLAUDE_OPUS_4_5 = exports.BEDROCK_CLAUDE_SONNET = exports.BEDROCK_CLAUDE_OPUS = void 0;
29
29
  const sendNotification_1 = require("./sendNotification");
30
30
  Object.defineProperty(exports, "sendNotification", { enumerable: true, get: function () { return sendNotification_1.sendNotification; } });
31
31
  const sleep_1 = require("./sleep");
@@ -109,6 +109,12 @@ Object.defineProperty(exports, "describeTextFile", { enumerable: true, get: func
109
109
  Object.defineProperty(exports, "analyzeFile", { enumerable: true, get: function () { return mediaProcessing_1.analyzeFile; } });
110
110
  Object.defineProperty(exports, "processMediaFile", { enumerable: true, get: function () { return mediaProcessing_1.processMediaFile; } });
111
111
  Object.defineProperty(exports, "reprocessRecentMedia", { enumerable: true, get: function () { return mediaProcessing_1.reprocessRecentMedia; } });
112
+ Object.defineProperty(exports, "reprocessMediaByIds", { enumerable: true, get: function () { return mediaProcessing_1.reprocessMediaByIds; } });
113
+ Object.defineProperty(exports, "corregirTranscripcion", { enumerable: true, get: function () { return mediaProcessing_1.corregirTranscripcion; } });
114
+ Object.defineProperty(exports, "GLOSARIO_RUNNING", { enumerable: true, get: function () { return mediaProcessing_1.GLOSARIO_RUNNING; } });
115
+ Object.defineProperty(exports, "MARCADOR_SIN_VOZ", { enumerable: true, get: function () { return mediaProcessing_1.MARCADOR_SIN_VOZ; } });
116
+ Object.defineProperty(exports, "MARCADOR_NO_TRANSCRITO", { enumerable: true, get: function () { return mediaProcessing_1.MARCADOR_NO_TRANSCRITO; } });
117
+ Object.defineProperty(exports, "MARCADORES_AUDIO", { enumerable: true, get: function () { return mediaProcessing_1.MARCADORES_AUDIO; } });
112
118
  const prompt_1 = require("./prompt");
113
119
  Object.defineProperty(exports, "generateObject", { enumerable: true, get: function () { return prompt_1.generateObject; } });
114
120
  Object.defineProperty(exports, "generateText", { enumerable: true, get: function () { return prompt_1.generateText; } });
@@ -12,115 +12,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.SYNC_MAX_SECONDS = exports.AUDIO_ENCODING_MAP = exports.convertToWav16k = exports.transcribeWithModel = exports.transcribeWav16k = exports.transcribeAudio = void 0;
15
+ exports.MAX_CARACTERES_POR_SEGUNDO = exports.TRANSCRIPTION_MAX_SECONDS = exports.MARCADORES_AUDIO = exports.MARCADOR_ANTIGUO = exports.MARCADOR_NO_TRANSCRITO = exports.MARCADOR_SIN_VOZ = exports.convertToWav16k = exports.transcribeWav16k = exports.transcribeAudio = void 0;
16
16
  const fs_1 = __importDefault(require("fs"));
17
17
  const path_1 = __importDefault(require("path"));
18
- const speech_1 = require("@google-cloud/speech");
19
18
  const ffmpegUtils_1 = require("./ffmpegUtils");
20
- const index_1 = require("../prompt/index");
21
- const constants_1 = require("../prompt/constants");
22
- const index_2 = require("../err/index");
23
- // ✅ Mapeo de MIME types a encoding de Speech-to-Text. Solo se usa en el camino de emergencia,
24
- // cuando ffmpeg no ha podido convertir el archivo y hay que mandarlo tal cual.
25
- const AUDIO_ENCODING_MAP = {
26
- 'audio/mpeg': 'MP3',
27
- 'audio/mp3': 'MP3',
28
- 'audio/wav': 'LINEAR16',
29
- 'audio/wave': 'LINEAR16',
30
- 'audio/x-wav': 'LINEAR16',
31
- 'audio/ogg': 'OGG_OPUS',
32
- 'audio/webm': 'WEBM_OPUS',
33
- 'audio/flac': 'FLAC',
34
- 'audio/x-flac': 'FLAC',
35
- 'audio/mp4': 'MP3',
36
- 'audio/m4a': 'MP3',
37
- 'audio/aac': 'MP3',
38
- };
39
- exports.AUDIO_ENCODING_MAP = AUDIO_ENCODING_MAP;
40
- // ✅ `recognize` (síncrono) rechaza audio de más de 60s. Cuando se le declara LINEAR16 deduce la
41
- // duración exacta de los bytes y lo aplica a rajatabla, así que por encima de esto no se le manda.
42
- const SYNC_MAX_SECONDS = 55;
43
- exports.SYNC_MAX_SECONDS = SYNC_MAX_SECONDS;
44
- // ✅ Speech-to-Text admite 10 MB en línea; a 32 KB/s son ~290s. Gemini aguanta mucho más,
45
- // pero recortamos para acotar el coste y la latencia de una nota de voz.
19
+ const azureSpeech_1 = require("./azureSpeech");
20
+ const azureTranscribe_1 = require("./azureTranscribe");
21
+ const correccionTranscripcion_1 = require("./correccionTranscripcion");
22
+ // ✅ Speech admite hasta 5 h por audio, pero se recorta para acotar coste y latencia de una nota de voz
46
23
  const TRANSCRIPTION_MAX_SECONDS = 600;
47
- const TRANSCRIPTION_PROMPT = `Transcribe literalmente lo que se dice en este audio. Es una nota de voz que un corredor manda a su entrenador.
48
- Devuelve SOLO la transcripción en su idioma original, sin comentarios, sin introducción y sin describir el audio.
49
- Si de verdad no se oye ninguna voz, responde exactamente: SIN_VOZ`;
24
+ exports.TRANSCRIPTION_MAX_SECONDS = TRANSCRIPTION_MAX_SECONDS;
25
+ // Nadie habla tan rápido: por encima de esto el texto no ha salido del audio. El máximo real medido
26
+ // sobre 74 notas de voz es 19. Es la red final contra cualquier motor que se invente contenido.
27
+ const MAX_CARACTERES_POR_SEGUNDO = 25;
28
+ exports.MAX_CARACTERES_POR_SEGUNDO = MAX_CARACTERES_POR_SEGUNDO;
29
+ // ✅ Marcadores que se guardan en FILE TEXT cuando no hay transcripción. Nunca se guarda vacío ni NULL:
30
+ // el entrenador se salta la respuesta si un adjunto no tiene texto y no distingue «sin texto» de
31
+ // «aún sin procesar».
32
+ const MARCADOR_SIN_VOZ = '[Audio sin voz reconocible]';
33
+ exports.MARCADOR_SIN_VOZ = MARCADOR_SIN_VOZ;
34
+ const MARCADOR_NO_TRANSCRITO = '[Audio no transcrito]';
35
+ exports.MARCADOR_NO_TRANSCRITO = MARCADOR_NO_TRANSCRITO;
36
+ const MARCADOR_ANTIGUO = '[Audio sin contenido reconocible]';
37
+ exports.MARCADOR_ANTIGUO = MARCADOR_ANTIGUO;
38
+ const MARCADORES_AUDIO = [MARCADOR_SIN_VOZ, MARCADOR_NO_TRANSCRITO, MARCADOR_ANTIGUO];
39
+ exports.MARCADORES_AUDIO = MARCADORES_AUDIO;
50
40
  /**
51
- * Llama a Speech-to-Text con un buffer y una configuración ya decididas
52
- *
53
- * @param content - Buffer del audio
54
- * @param encoding - Encoding declarado (LINEAR16, MP3...)
55
- * @returns Promise<string | null> - Transcripción, o null si no se reconoció nada
56
- */
57
- const recognizeSpeech = (content, encoding) => __awaiter(void 0, void 0, void 0, function* () {
58
- var _a;
59
- try {
60
- const speechClient = new speech_1.SpeechClient();
61
- // @ts-ignore - Tipos de Speech-to-Text son complejos
62
- const [response] = yield speechClient.recognize({
63
- audio: {
64
- content: content.toString('base64'),
65
- },
66
- config: {
67
- // @ts-ignore - El encoding se determina dinámicamente
68
- encoding: encoding,
69
- sampleRateHertz: ffmpegUtils_1.SPEECH_SAMPLE_RATE,
70
- languageCode: 'es-ES', // Español de España
71
- alternativeLanguageCodes: ['es-MX', 'es-AR', 'en-US'], // Alternativas: México, Argentina, Inglés
72
- enableAutomaticPunctuation: true, // Puntuación automática
73
- model: 'latest_long', // Modelo más reciente para audios largos
74
- useEnhanced: true, // Modelo mejorado
75
- },
76
- });
77
- const transcription = (_a = response.results) === null || _a === void 0 ? void 0 : _a.map((result) => { var _a, _b; return (_b = (_a = result.alternatives) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.transcript; }).filter(Boolean).join(' ').trim();
78
- return transcription || null;
79
- }
80
- catch (error) {
81
- // eslint-disable-next-line no-console
82
- console.error('[transcribeAudio] Error Speech-to-Text:', (error === null || error === void 0 ? void 0 : error.message) || error);
83
- return null;
84
- }
85
- });
86
- /**
87
- * Transcribe con un modelo multimodal de Google. Es la red que recoge lo que Speech-to-Text
88
- * no reconoce y, sobre todo, los audios de más de 60s, que el modo síncrono no admite.
89
- *
90
- * @param wavBuffer - Buffer WAV mono 16 kHz
91
- * @returns Promise<string | null> - Transcripción, o null si no hay voz
92
- */
93
- const transcribeWithModel = (wavBuffer) => __awaiter(void 0, void 0, void 0, function* () {
94
- try {
95
- const model = (0, index_1.createGoogleModelFromString)(constants_1.GOOGLE_MODELS.FLASH);
96
- // El primario es Vertex; si el proyecto no lo tiene disponible se pasa a AI Studio
97
- model._fallbackModelNames = [`${constants_1.AISTUDIO_PREFIX}${constants_1.GOOGLE_MODELS.FLASH}`, 'gemini-2.5-flash', `${constants_1.AISTUDIO_PREFIX}gemini-2.5-flash`];
98
- const { text } = yield (0, index_1.generateText)({
99
- model,
100
- // ⭐ Sin registro en LLM CACHE: el audio no aporta nada como clave y engorda la tabla
101
- log: false,
102
- messages: [
103
- {
104
- role: 'user',
105
- content: [
106
- { type: 'file', data: wavBuffer, mediaType: 'audio/wav' },
107
- { type: 'text', text: TRANSCRIPTION_PROMPT },
108
- ],
109
- },
110
- ],
111
- });
112
- const transcription = (text || '').trim();
113
- return !transcription || transcription === 'SIN_VOZ' ? null : transcription;
114
- }
115
- catch (error) {
116
- // eslint-disable-next-line no-console
117
- console.error('[transcribeAudio] Error transcribiendo con el modelo:', (error === null || error === void 0 ? void 0 : error.message) || error);
118
- return null;
119
- }
120
- });
121
- exports.transcribeWithModel = transcribeWithModel;
122
- /**
123
- * Convierte cualquier audio al formato que espera Speech-to-Text (WAV PCM mono 16 kHz)
41
+ * Convierte cualquier audio a WAV PCM mono 16 kHz, recortado a 10 minutos
124
42
  *
125
43
  * @param fileBuffer - Buffer del audio original
126
44
  * @param filename - Nombre original del archivo
@@ -145,72 +63,168 @@ const convertToWav16k = (fileBuffer, filename) => (0, ffmpegUtils_1.withTempDir)
145
63
  }));
146
64
  exports.convertToWav16k = convertToWav16k;
147
65
  /**
148
- * Transcribe una nota de voz.
149
- *
150
- * El audio se normaliza a WAV mono 16 kHz y luego se elige motor por duración:
151
- * - hasta 55s → Speech-to-Text, que es barato y preciso.
152
- * - más de 55s → modelo multimodal, porque `recognize` no admite audios más largos y
153
- * `longRunningRecognize` exige subir el archivo a GCS antes.
154
- * - si Speech-to-Text no reconoce nada, se reintenta con el modelo antes de darlo por perdido.
155
- *
156
- * ⚠️ El orden importa y no es intercambiable. Medido sobre notas de voz reales de producción
157
- * que hoy acaban en '[Audio sin contenido reconocible]':
158
- * - mandar el AAC crudo declarando 'MP3' (lo que se hacía): 0 de 14 recuperadas.
159
- * - convertir a WAV y declarar LINEAR16: 13 de 14 (la que falla es de 81s).
160
- * - modelo multimodal: 14 de 14.
161
- * Y a la inversa, convertir a WAV SIN respetar el límite de 55s rompe los audios largos que hoy
162
- * sí funcionan (4 de 4 medidos): con LINEAR16 la API deduce la duración exacta y los rechaza,
163
- * mientras que con un códec comprimido declarado se saltaba esa comprobación.
66
+ * Mide duración y volumen medio del audio. Si ffmpeg falla se sigue con lo que hay: sin medida
67
+ * no se puede aplicar la guarda de silencio, pero sí transcribir.
164
68
  *
165
- * @param fileBuffer - Buffer del archivo de audio
166
- * @param mimetype - Tipo MIME del archivo (ej: 'audio/aac', 'audio/x-m4a')
69
+ * @param fileBuffer - Buffer del audio
167
70
  * @param filename - Nombre original del archivo
168
- * @returns Promise<string> - Transcripción del audio
71
+ * @returns Duración en segundos (0 si no se pudo medir) y volumen medio en dB (null si no se pudo)
72
+ */
73
+ const medirAudio = (fileBuffer, filename) => __awaiter(void 0, void 0, void 0, function* () {
74
+ try {
75
+ return yield (0, ffmpegUtils_1.withTempDir)((dir) => __awaiter(void 0, void 0, void 0, function* () {
76
+ const inputPath = path_1.default.join(dir, `input${path_1.default.extname(filename || '') || '.m4a'}`);
77
+ fs_1.default.writeFileSync(inputPath, fileBuffer);
78
+ const [{ duration }, meanDb] = yield Promise.all([(0, ffmpegUtils_1.probeMedia)(inputPath), (0, ffmpegUtils_1.measureLoudness)(inputPath)]);
79
+ return { duration, meanDb };
80
+ }));
81
+ }
82
+ catch (error) {
83
+ // eslint-disable-next-line no-console
84
+ console.error('[transcribeAudio] No se pudo medir el audio:', (error === null || error === void 0 ? void 0 : error.message) || error);
85
+ return { duration: 0, meanDb: null };
86
+ }
87
+ });
88
+ /**
89
+ * Pasa el audio por los motores en orden: Azure Speech y, si falla o no oye nada con señal,
90
+ * gpt-4o-mini-transcribe sobre WAV. Cada motor falla por su cuenta; nunca lanza.
169
91
  *
170
- * @example
171
- * ```typescript
172
- * const transcription = await transcribeAudio(audioBuffer, 'audio/aac', 'audio.aac');
173
- * // Resultado: "Hola, quería comentarte que hoy me duele un poco la rodilla..."
174
- * ```
92
+ * @param params - Audio original, WAV si ya existe, duración, locale y frases a reforzar
93
+ * @returns Texto (vacío si ningún motor oyó nada), motor que lo produjo y su confianza
175
94
  */
176
- const transcribeAudio = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
177
- let wav;
178
- let duration;
95
+ const reconocer = (params) => __awaiter(void 0, void 0, void 0, function* () {
96
+ let motor = 'ninguno';
179
97
  try {
180
- ({ wav, duration } = yield convertToWav16k(fileBuffer, filename));
98
+ const { text, confidence } = yield (0, azureSpeech_1.transcribeWithAzureSpeech)(params.wav || params.original, {
99
+ locale: params.locale,
100
+ phrases: params.phrases,
101
+ mimetype: params.wav ? 'audio/wav' : params.mimetype,
102
+ });
103
+ motor = 'speech';
104
+ // ⭐ Un audio de menos de 1 s sin resultado no merece un segundo motor: la guarda física ya
105
+ // decidió que hay señal, pero no hay nada que un modelo pueda oír ahí
106
+ if (text || params.duration < 1)
107
+ return { text, motor, confidence };
181
108
  }
182
109
  catch (error) {
183
- // ⭐ Sin ffmpeg no se puede elegir motor: se manda el original tal cual, como se hacía antes
184
110
  // eslint-disable-next-line no-console
185
- console.error('[transcribeAudio] No se pudo convertir el audio, se envía sin normalizar:', (error === null || error === void 0 ? void 0 : error.message) || error);
186
- (0, index_2.err)(null, null, error, null);
187
- const mime = (mimetype || '').toLowerCase().split(';')[0].trim();
188
- return (yield recognizeSpeech(fileBuffer, AUDIO_ENCODING_MAP[mime] || 'MP3')) || '[Audio sin contenido reconocible]';
111
+ console.error('[transcribeAudio] Azure Speech falló, se pasa al respaldo:', (error === null || error === void 0 ? void 0 : error.message) || error);
189
112
  }
190
- if (duration > 0 && duration <= SYNC_MAX_SECONDS) {
191
- const transcription = yield recognizeSpeech(wav, 'LINEAR16');
192
- if (transcription)
193
- return transcription;
113
+ try {
114
+ const wav = params.wav || (yield convertToWav16k(params.original, params.filename)).wav;
115
+ const text = yield (0, azureTranscribe_1.transcribeWithAzureOpenAI)(wav, { language: params.locale.slice(0, 2) });
116
+ return { text, motor: 'openai', confidence: null };
194
117
  }
195
- const fromModel = yield transcribeWithModel(wav);
196
- if (fromModel) {
197
- return duration > TRANSCRIPTION_MAX_SECONDS
198
- ? `${fromModel} (Solo se transcribieron los primeros ${Math.round(TRANSCRIPTION_MAX_SECONDS / 60)} minutos.)`
199
- : fromModel;
118
+ catch (error) {
119
+ // eslint-disable-next-line no-console
120
+ console.error('[transcribeAudio] El respaldo de Azure OpenAI también falló:', (error === null || error === void 0 ? void 0 : error.message) || error);
121
+ return { text: '', motor, confidence: null };
200
122
  }
201
- return '[Audio sin contenido reconocible]';
123
+ });
124
+ /**
125
+ * Cadena completa sobre un audio ya medido: guarda de silencio → motores → guarda de densidad →
126
+ * corrección → una línea de log por audio (motor, duración, dB, confianza, caracteres, latencia).
127
+ *
128
+ * @param params - Audio original, WAV opcional, medidas y contexto
129
+ * @returns Transcripción o marcador
130
+ */
131
+ const transcribirMedido = (params) => __awaiter(void 0, void 0, void 0, function* () {
132
+ var _a, _b;
133
+ const inicio = Date.now();
134
+ const { duration, meanDb } = params.medida;
135
+ const registrar = (datos) => {
136
+ var _a, _b;
137
+ // eslint-disable-next-line no-console
138
+ console.log('[transcribeAudio]', JSON.stringify(Object.assign({ id: (_b = (_a = params.contexto) === null || _a === void 0 ? void 0 : _a.idMensaje) !== null && _b !== void 0 ? _b : null, dur: Number(duration.toFixed(1)), db: meanDb, ms: Date.now() - inicio }, datos)));
139
+ };
140
+ if ((0, ffmpegUtils_1.esAudioSinVoz)(duration, meanDb)) {
141
+ registrar({ motor: 'ninguno', resultado: 'sin voz' });
142
+ return MARCADOR_SIN_VOZ;
143
+ }
144
+ const locale = (0, azureSpeech_1.localeFromPreferredLanguage)((_a = params.contexto) === null || _a === void 0 ? void 0 : _a.idioma);
145
+ const phrases = [...(((_b = params.contexto) === null || _b === void 0 ? void 0 : _b.nombres) || []), ...correccionTranscripcion_1.GLOSARIO_RUNNING];
146
+ // ⭐ Solo se convierte a WAV si hay que recortar: Speech acepta el archivo original tal cual
147
+ let wav = params.wav || null;
148
+ const recortado = duration > TRANSCRIPTION_MAX_SECONDS;
149
+ if (recortado && !wav) {
150
+ try {
151
+ wav = (yield convertToWav16k(params.original, params.filename)).wav;
152
+ }
153
+ catch (error) {
154
+ // eslint-disable-next-line no-console
155
+ console.error('[transcribeAudio] No se pudo recortar el audio, se manda entero:', (error === null || error === void 0 ? void 0 : error.message) || error);
156
+ }
157
+ }
158
+ const { text, motor, confidence } = yield reconocer({
159
+ original: params.original,
160
+ wav,
161
+ duration,
162
+ locale,
163
+ phrases,
164
+ mimetype: params.mimetype,
165
+ filename: params.filename,
166
+ });
167
+ if (!text) {
168
+ registrar({ motor, resultado: 'no transcrito' });
169
+ return MARCADOR_NO_TRANSCRITO;
170
+ }
171
+ if (duration > 0 && text.length / duration > MAX_CARACTERES_POR_SEGUNDO) {
172
+ registrar({ motor, resultado: 'densidad imposible', chars: text.length });
173
+ return MARCADOR_NO_TRANSCRITO;
174
+ }
175
+ const { texto, sustituciones } = yield (0, correccionTranscripcion_1.corregirTranscripcion)(text, params.contexto);
176
+ registrar({
177
+ motor,
178
+ conf: confidence,
179
+ chars: texto.length,
180
+ sustituciones: `${sustituciones.filter((sustitucion) => sustitucion.aceptada).length}/${sustituciones.length}`,
181
+ });
182
+ return recortado && wav ? `${texto} (Solo se transcribieron los primeros ${Math.round(TRANSCRIPTION_MAX_SECONDS / 60)} minutos.)` : texto;
183
+ });
184
+ /**
185
+ * Transcribe una nota de voz.
186
+ *
187
+ * Cadena, toda en Azure: medir duración y volumen con ffmpeg → si no hay voz, marcador sin llamar
188
+ * a nadie → Azure AI Speech con el locale del cliente y una lista de frases (nombres + jerga) →
189
+ * si falla o no oye nada, gpt-4o-mini-transcribe sin prompt → si el texto es físicamente imposible
190
+ * (más de 25 caracteres por segundo), marcador → corrección de jerga y nombres con el modelo LITE
191
+ * detrás de un filtro determinista.
192
+ *
193
+ * ⚠️ Medido sobre 74 notas de voz reales (06-sep-2026): Google Speech-to-Text cambiaba a inglés al
194
+ * azar en 2 de 74 (la identificación multi-idioma) y dejaba vacíos 3 audios con voz; los modelos
195
+ * generativos (Gemini, gpt-audio, transcribe con prompt) escribían una nota entera ante clips en
196
+ * silencio. Azure Speech: 0 invenciones, 0 vacíos con voz, p50 1,2 s.
197
+ *
198
+ * @param fileBuffer - Buffer del archivo de audio (aac, m4a, mp3, wav, ogg…)
199
+ * @param mimetype - Tipo MIME del archivo (ej: 'audio/aac', 'audio/x-m4a')
200
+ * @param filename - Nombre original del archivo
201
+ * @param contexto - Idioma, nombres y últimos mensajes del cliente (opcional)
202
+ * @returns Promise<string> - Transcripción, o un marcador si no hay voz o no se pudo transcribir
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * const transcription = await transcribeAudio(audioBuffer, 'audio/aac', 'audio.aac', { idioma: 'es', nombres: ['Ana', 'Rubén'] });
207
+ * // Resultado: "Hola Rubén, quería comentarte que hoy me duele un poco la rodilla..."
208
+ * ```
209
+ */
210
+ const transcribeAudio = (fileBuffer, mimetype, filename, contexto) => __awaiter(void 0, void 0, void 0, function* () {
211
+ const medida = yield medirAudio(fileBuffer, filename);
212
+ return transcribirMedido({ original: fileBuffer, medida, mimetype, filename, contexto });
202
213
  });
203
214
  exports.transcribeAudio = transcribeAudio;
204
215
  /**
205
- * Transcribe un WAV que ya viene en el formato que espera Speech-to-Text
206
- * (PCM 16 bits, mono, 16 kHz) y dura menos de 55 segundos. Lo usa el análisis de vídeo,
207
- * que extrae la pista así y ya la recorta.
216
+ * Transcribe un WAV que ya viene en PCM 16 bits, mono, 16 kHz. Lo usa el análisis de vídeo, que
217
+ * extrae la pista así y ya la recorta.
208
218
  *
209
- * @param wavBuffer - Buffer WAV mono 16 kHz de menos de 55 segundos
210
- * @returns Promise<string> - Transcripción del audio
219
+ * @param wavBuffer - Buffer WAV mono 16 kHz
220
+ * @param contexto - Idioma, nombres y últimos mensajes del cliente (opcional)
221
+ * @returns Promise<string> - Transcripción, o un marcador
211
222
  */
212
- const transcribeWav16k = (wavBuffer) => __awaiter(void 0, void 0, void 0, function* () {
213
- const transcription = (yield recognizeSpeech(wavBuffer, 'LINEAR16')) || (yield transcribeWithModel(wavBuffer));
214
- return transcription || '[Audio sin contenido reconocible]';
223
+ const transcribeWav16k = (wavBuffer, contexto) => __awaiter(void 0, void 0, void 0, function* () {
224
+ const medida = yield medirAudio(wavBuffer, 'audio.wav');
225
+ // La duración de un WAV PCM se sabe por los bytes aunque ffprobe fallara
226
+ if (!medida.duration)
227
+ medida.duration = wavBuffer.length / (ffmpegUtils_1.SPEECH_SAMPLE_RATE * 2);
228
+ return transcribirMedido({ original: wavBuffer, wav: wavBuffer, medida, mimetype: 'audio/wav', filename: 'audio.wav', contexto });
215
229
  });
216
230
  exports.transcribeWav16k = transcribeWav16k;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ // ✅ Transcripción rápida de Azure AI Speech. Vive en el mismo recurso AIServices que Azure OpenAI,
3
+ // así que va con la misma clave (AZURE_API_KEY) y no hay que desplegar nada.
4
+ // Medido sobre 74 notas de voz reales (06-sep-2026): 0 invenciones, 0 vacíos con voz, p50 1,2 s.
5
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
6
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
7
+ return new (P || (P = Promise))(function (resolve, reject) {
8
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
9
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
10
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
11
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
12
+ });
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.SPEECH_API_VERSION = exports.localeFromPreferredLanguage = exports.transcribeWithAzureSpeech = void 0;
16
+ // ✅ Versión que admite `phraseList` (la 2024-11-15 no la tiene)
17
+ const SPEECH_API_VERSION = '2025-10-15';
18
+ exports.SPEECH_API_VERSION = SPEECH_API_VERSION;
19
+ // ✅ Techo generoso: admite audios de hasta 5 h, aunque aquí se recortan a 10 min antes
20
+ const SPEECH_TIMEOUT_MS = 120000;
21
+ // ✅ La lista de frases empuja el reconocimiento hacia esas palabras; con demasiadas deja de ayudar
22
+ const SPEECH_MAX_PHRASES = 80;
23
+ // ✅ Un solo locale por llamada. La identificación automática de idioma entre varios locales es
24
+ // justo lo que hacía que Google devolviera inglés al azar; aquí el idioma viene de la ficha del cliente.
25
+ const LOCALE_POR_IDIOMA = {
26
+ es: 'es-ES',
27
+ en: 'en-US',
28
+ ca: 'ca-ES',
29
+ pt: 'pt-PT',
30
+ fr: 'fr-FR',
31
+ it: 'it-IT',
32
+ de: 'de-DE',
33
+ };
34
+ /**
35
+ * Traduce el idioma preferido del cliente (`CLIENTE."PREFERRED LANGUAGE"`) al locale de Speech
36
+ *
37
+ * @param lang - Código de idioma ('es', 'en', 'ca'…), o vacío
38
+ * @returns Locale BCP-47; 'es-ES' si no se reconoce
39
+ */
40
+ const localeFromPreferredLanguage = (lang) => LOCALE_POR_IDIOMA[(lang || '').toLowerCase().slice(0, 2)] || 'es-ES';
41
+ exports.localeFromPreferredLanguage = localeFromPreferredLanguage;
42
+ /**
43
+ * URL y clave de la transcripción rápida. Por defecto se derivan del recurso de Azure OpenAI
44
+ * (mismo dominio, mismo secreto); `AZURE_SPEECH_URL` y `AZURE_SPEECH_KEY` permiten separarlos.
45
+ *
46
+ * @returns URL completa del endpoint y clave
47
+ * @throws Error si no hay configuración suficiente
48
+ */
49
+ const getSpeechConfig = () => {
50
+ const key = process.env.AZURE_SPEECH_KEY || process.env.AZURE_API_KEY;
51
+ if (!key)
52
+ throw new Error('Falta AZURE_SPEECH_KEY o AZURE_API_KEY');
53
+ if (process.env.AZURE_SPEECH_URL)
54
+ return { url: process.env.AZURE_SPEECH_URL, key };
55
+ if (!process.env.AZURE_BASE_URL)
56
+ throw new Error('Falta AZURE_SPEECH_URL o AZURE_BASE_URL');
57
+ const origin = new URL(process.env.AZURE_BASE_URL).origin;
58
+ return { url: `${origin}/speechtotext/transcriptions:transcribe?api-version=${SPEECH_API_VERSION}`, key };
59
+ };
60
+ const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
61
+ /**
62
+ * Transcribe un audio con la transcripción rápida de Azure AI Speech.
63
+ *
64
+ * Acepta el archivo tal cual (aac, m4a, mp3, wav, ogg…): Speech detecta el formato por contenido,
65
+ * así que no hace falta convertir a WAV. Sin filtro de palabrotas, porque «joder» forma parte de
66
+ * lo que el cliente dijo y el entrenador tiene que verlo.
67
+ *
68
+ * @param audio - Buffer del audio
69
+ * @param opts - locale del cliente, frases a reforzar (nombres, jerga) y MIME type si se conoce
70
+ * @returns Texto, confianza media de las frases y duración detectada
71
+ * @throws Error si la API responde con error (tras un reintento en 429/5xx)
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * const { text } = await transcribeWithAzureSpeech(buffer, { locale: 'es-ES', phrases: ['Rubén', 'fartlek'] });
76
+ * ```
77
+ */
78
+ const transcribeWithAzureSpeech = (audio, opts) => __awaiter(void 0, void 0, void 0, function* () {
79
+ const { url, key } = getSpeechConfig();
80
+ const phrases = [...new Set((opts.phrases || []).map((phrase) => (phrase || '').trim()).filter(Boolean))].slice(0, SPEECH_MAX_PHRASES);
81
+ const definition = { locales: [opts.locale], profanityFilterMode: 'None' };
82
+ if (phrases.length)
83
+ definition.phraseList = { phrases };
84
+ const enviar = () => __awaiter(void 0, void 0, void 0, function* () {
85
+ const form = new FormData();
86
+ form.append('audio', new Blob([audio], { type: opts.mimetype || 'application/octet-stream' }), 'audio');
87
+ form.append('definition', JSON.stringify(definition));
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), SPEECH_TIMEOUT_MS);
90
+ try {
91
+ return yield fetch(url, { method: 'POST', headers: { 'Ocp-Apim-Subscription-Key': key }, body: form, signal: controller.signal });
92
+ }
93
+ finally {
94
+ clearTimeout(timer);
95
+ }
96
+ });
97
+ let response = yield enviar();
98
+ if (response.status === 429 || response.status >= 500) {
99
+ yield esperar(3000);
100
+ response = yield enviar();
101
+ }
102
+ const body = yield response.json().catch(() => ({}));
103
+ if (!response.ok)
104
+ throw new Error(`Azure Speech HTTP ${response.status}: ${JSON.stringify(body).slice(0, 200)}`);
105
+ const text = (body.combinedPhrases || [])
106
+ .map((phrase) => phrase === null || phrase === void 0 ? void 0 : phrase.text)
107
+ .filter(Boolean)
108
+ .join(' ')
109
+ .trim();
110
+ const confidences = (body.phrases || []).map((phrase) => phrase === null || phrase === void 0 ? void 0 : phrase.confidence).filter((value) => typeof value === 'number');
111
+ const confidence = confidences.length ? Number((confidences.reduce((sum, value) => sum + value, 0) / confidences.length).toFixed(3)) : null;
112
+ return { text, confidence, durationMs: typeof body.durationMilliseconds === 'number' ? body.durationMilliseconds : null };
113
+ });
114
+ exports.transcribeWithAzureSpeech = transcribeWithAzureSpeech;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ // ✅ Respaldo de transcripción con Azure OpenAI (gpt-4o-mini-transcribe). Solo entra cuando Azure
3
+ // Speech falla o devuelve vacío con señal. Va SIN `prompt` a propósito: con un prompt de vocabulario
4
+ // estos modelos se inventan una nota de voz entera ante un clip en silencio (medido 06-sep-2026).
5
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
6
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
7
+ return new (P || (P = Promise))(function (resolve, reject) {
8
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
9
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
10
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
11
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
12
+ });
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.TRANSCRIBE_API_VERSION = exports.getTranscribeConfig = exports.transcribeWithAzureOpenAI = void 0;
16
+ // ✅ La ruta v1 (/openai/v1/audio/transcriptions) devuelve DeploymentNotFound en este recurso;
17
+ // la de deployments/ sí responde
18
+ const TRANSCRIBE_API_VERSION = '2025-04-01-preview';
19
+ exports.TRANSCRIBE_API_VERSION = TRANSCRIBE_API_VERSION;
20
+ const TRANSCRIBE_TIMEOUT_MS = 120000;
21
+ const TRANSCRIBE_DEPLOYMENT_POR_DEFECTO = 'gpt-4o-mini-transcribe';
22
+ const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
+ /**
24
+ * URL y clave del despliegue de transcripción de Azure OpenAI
25
+ *
26
+ * @returns URL completa del endpoint y clave
27
+ * @throws Error si falta AZURE_BASE_URL o AZURE_API_KEY
28
+ */
29
+ const getTranscribeConfig = () => {
30
+ const key = process.env.AZURE_API_KEY;
31
+ const base = (process.env.AZURE_BASE_URL || '').replace(/\/$/, '');
32
+ if (!key || !base)
33
+ throw new Error('Faltan AZURE_BASE_URL o AZURE_API_KEY');
34
+ const deployment = process.env.AZURE_TRANSCRIBE_DEPLOYMENT || TRANSCRIBE_DEPLOYMENT_POR_DEFECTO;
35
+ return { url: `${base}/deployments/${deployment}/audio/transcriptions?api-version=${TRANSCRIBE_API_VERSION}`, key, deployment };
36
+ };
37
+ exports.getTranscribeConfig = getTranscribeConfig;
38
+ /**
39
+ * Transcribe un WAV con el despliegue de transcripción de Azure OpenAI, sin prompt.
40
+ *
41
+ * @param wav - Buffer WAV (mono 16 kHz, como sale de `convertToWav16k`)
42
+ * @param opts - Idioma en código de dos letras ('es', 'en'…)
43
+ * @returns Texto transcrito; cadena vacía si el modelo no oyó nada (un "." suelto cuenta como vacío)
44
+ * @throws Error si la API responde con error (tras un reintento en 429/5xx)
45
+ */
46
+ const transcribeWithAzureOpenAI = (wav, opts) => __awaiter(void 0, void 0, void 0, function* () {
47
+ const { url, key } = getTranscribeConfig();
48
+ const enviar = () => __awaiter(void 0, void 0, void 0, function* () {
49
+ const form = new FormData();
50
+ form.append('file', new Blob([wav], { type: 'audio/wav' }), 'audio.wav');
51
+ form.append('language', opts.language);
52
+ form.append('response_format', 'json');
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), TRANSCRIBE_TIMEOUT_MS);
55
+ try {
56
+ return yield fetch(url, { method: 'POST', headers: { 'api-key': key }, body: form, signal: controller.signal });
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ });
62
+ let response = yield enviar();
63
+ if (response.status === 429 || response.status >= 500) {
64
+ yield esperar(3000);
65
+ response = yield enviar();
66
+ }
67
+ const body = yield response.json().catch(() => ({}));
68
+ if (!response.ok)
69
+ throw new Error(`Azure OpenAI transcribe HTTP ${response.status}: ${JSON.stringify(body).slice(0, 200)}`);
70
+ const text = (body.text || '').trim();
71
+ return /^[.\s]*$/.test(text) ? '' : text;
72
+ });
73
+ exports.transcribeWithAzureOpenAI = transcribeWithAzureOpenAI;