PT-BR or Spanish transcription with speaker diarization

Manual, fail-closed flow for Brazilian Portuguese or Spanish transcription with speaker diarization, word timestamps, SRT and WebVTT output. It uses Node-RED core nodes only.

Setup and source: https://github.com/fasuizu-br/brainiall-node-red-diarized-transcription-flow

Step-by-step guide: https://one-percent-lab.fabiosuizu.chatgpt.site/transcreve/integracoes/node-red-transcricao-diarizacao

Machine-readable output schema: https://github.com/fasuizu-br/brainiall-node-red-diarized-transcription-flow/blob/v1.1.0/schemas/transcription-result.schema.json

Validated release v1.1.0 (43 assertions): https://github.com/fasuizu-br/brainiall-node-red-diarized-transcription-flow/releases/tag/v1.1.0

Create a dedicated revocable BRAINIALL key at https://app.brainiall.com?utm_source=node_red&utm_medium=flow_library&utm_campaign=pt_br_diarized_transcription

Before running, set BRAINIALL_API_KEY in the Node-RED runtime, choose one local media file you are authorized to process, set language to pt or es, and change rightsAndConsent to true. The default is false, so importing the flow cannot upload anything.

The hosted API is external and metered. The request is attempted once; speaker labels and captions require human review. No file path, key, media, transcript, SRT or VTT is sent to the included Debug node. The JSON Schema describes this flow's output contract, not the upstream API response.

[{"id":"c25brainiallstt","type":"tab","label":"Brainiall PT-BR / ES diarized transcription","disabled":false,"info":"Manual, fail-closed transcription flow. It requires an explicit absolute local file path, rightsAndConsent=true, pt or es, and BRAINIALL_API_KEY in the environment. It never logs the key, media, transcript, SRT, or VTT.","env":[]},{"id":"c25instructions","type":"comment","z":"c25brainiallstt","name":"Edit the Inject payload, confirm rights and consent, then click once","info":"The default rightsAndConsent value is false. Set it to true only for media you are authorized to send to BRAINIALL. The hosted endpoint is external and metered; there is no automatic retry.","x":330,"y":80,"wires":[]},{"id":"c25manualinject","type":"inject","z":"c25brainiallstt","name":"Manual authorized media","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"brainiall-stt-c25","payload":"{\"file\":\"/absolute/path/to/authorized-recording.wav\",\"language\":\"pt\",\"rightsAndConsent\":false}","payloadType":"json","x":170,"y":170,"wires":[["c25preflight"]]},{"id":"c25preflight","type":"function","z":"c25brainiallstt","name":"Consent, path, language, and key gate","func":"const input = msg.payload;\nif (!input || typeof input !== 'object' || Array.isArray(input)) {\n    node.error('Inject payload must be an object.', msg);\n    return null;\n}\nif (input.rightsAndConsent !== true) {\n    node.error('Confirm rights and speaker consent before processing media.', msg);\n    return null;\n}\nconst file = typeof input.file === 'string' ? input.file.trim() : '';\nif (!file.startsWith('/') || file.length > 4096 || /[\\r\\n\\0]/.test(file)) {\n    node.error('file must be one absolute local path.', msg);\n    return null;\n}\nconst extensionMatch = file.toLowerCase().match(/(\\.m4a|\\.mp3|\\.mp4|\\.mpeg|\\.mpga|\\.ogg|\\.wav|\\.webm)$/);\nif (!extensionMatch) {\n    node.error('The media extension is not supported.', msg);\n    return null;\n}\nconst language = input.language;\nif (language !== 'pt' && language !== 'es') {\n    node.error('language must be pt or es.', msg);\n    return null;\n}\nconst apiKey = env.get('BRAINIALL_API_KEY');\nif (typeof apiKey !== 'string' || apiKey.length < 32 || apiKey.length > 4096 || apiKey !== apiKey.trim() || /[\\r\\n]/.test(apiKey)) {\n    node.error('BRAINIALL_API_KEY is missing or invalid.', msg);\n    return null;\n}\nconst contentTypes = { '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.mpeg': 'video/mpeg', '.mpga': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.webm': 'audio/webm' };\nmsg.filename = file;\nmsg.brainiallExtension = extensionMatch[1];\nmsg.brainiallContentType = contentTypes[extensionMatch[1]];\nmsg.brainiallLanguage = language;\nmsg.brainiallApiKey = apiKey;\ndelete msg.payload;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":460,"y":170,"wires":[["c25readfile"]]},{"id":"c25readfile","type":"file in","z":"c25brainiallstt","name":"Read selected file as Buffer","filename":"filename","filenameType":"msg","format":"","chunk":false,"sendError":true,"encoding":"none","allProps":true,"x":730,"y":170,"wires":[["c25prepare"]]},{"id":"c25prepare","type":"function","z":"c25brainiallstt","name":"Build bounded multipart request","func":"const media = msg.payload;\nif (!Buffer.isBuffer(media) || media.length < 1 || media.length > 25 * 1024 * 1024) {\n    node.error('Media must be a non-empty Buffer of at most 25 MB.', msg);\n    return null;\n}\nconst apiKey = msg.brainiallApiKey;\nconst extension = msg.brainiallExtension;\nconst contentType = msg.brainiallContentType;\nconst language = msg.brainiallLanguage;\nmsg.headers = {\n    Authorization: 'Bearer ' + apiKey,\n    Accept: 'application/json',\n    'Content-Type': 'multipart/form-data'\n};\nmsg.payload = {\n    audio: { value: media, options: { filename: 'upload' + extension, contentType } },\n    language,\n    diarize: 'true'\n};\nmsg.requestTimeout = 180000;\ndelete msg.filename;\ndelete msg.brainiallApiKey;\ndelete msg.brainiallExtension;\ndelete msg.brainiallContentType;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1010,"y":170,"wires":[["c25httprequest"]]},{"id":"c25httprequest","type":"http request","z":"c25brainiallstt","name":"POST Brainiall transcription once","method":"POST","ret":"obj","paytoqs":"ignore","url":"https://api.brainiall.com/v1/whisper/transcribe","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":true,"headers":[],"x":1290,"y":170,"wires":[["c25validate"]]},{"id":"c25validate","type":"function","z":"c25brainiallstt","name":"Validate words and render SRT/VTT","func":"const statusCode = Number(msg.statusCode || 0);\nconst body = msg.payload;\nconst language = msg.brainiallLanguage;\ndelete msg.headers;\ndelete msg.requestHeaders;\ndelete msg.responseUrl;\ndelete msg.brainiallLanguage;\nif (statusCode !== 200) {\n    msg.payload = { success: false, statusCode };\n    node.error('Brainiall transcription returned a non-200 status. Do not retry automatically.', msg);\n    return null;\n}\nif (!body || typeof body !== 'object' || Array.isArray(body) || !Array.isArray(body.words)) {\n    msg.payload = { success: false, statusCode };\n    node.error('Brainiall returned an invalid transcription response.', msg);\n    return null;\n}\nconst clean = value => String(value ?? '').replace(/\\s+/g, ' ').trim();\nconst words = body.words.map(item => ({\n    word: clean(item && (item.word ?? item.text)),\n    start: Number(item && item.start),\n    end: Number(item && item.end),\n    speaker: clean(item && item.speaker) || 'unknown'\n})).filter(item => item.word && Number.isFinite(item.start) && item.start >= 0 && Number.isFinite(item.end) && item.end > item.start).sort((a, b) => a.start - b.start || a.end - b.end);\nif (words.length < 1) {\n    msg.payload = { success: false, statusCode };\n    node.error('Brainiall returned no timestamped words.', msg);\n    return null;\n}\nconst speakerNumbers = new Map();\nconst numberFor = speaker => { if (!speakerNumbers.has(speaker)) speakerNumbers.set(speaker, speakerNumbers.size + 1); return speakerNumbers.get(speaker); };\nconst cues = [];\nlet current = [];\nconst join = items => items.map(item => item.word).join(' ').replace(/\\s+([,.;:!?%])/g, '$1');\nconst flush = () => { if (!current.length) return; cues.push({ start: current[0].start, end: current[current.length - 1].end, speaker: numberFor(current[0].speaker), text: join(current) }); current = []; };\nfor (const word of words) {\n    if (!current.length) { current.push(word); continue; }\n    const first = current[0];\n    const previous = current[current.length - 1];\n    if (word.speaker !== first.speaker || word.end - first.start > 6 || word.start - previous.end > 1.5 || join([...current, word]).length > 84) flush();\n    current.push(word);\n}\nflush();\nconst timestamp = (seconds, separator) => {\n    const total = Math.max(0, Math.round(seconds * 1000));\n    const hours = Math.floor(total / 3600000);\n    const minutes = Math.floor((total % 3600000) / 60000);\n    const secs = Math.floor((total % 60000) / 1000);\n    const millis = total % 1000;\n    return [hours, minutes, secs].map(value => String(value).padStart(2, '0')).join(':') + separator + String(millis).padStart(3, '0');\n};\nconst label = speaker => (language === 'pt' ? 'Falante ' : 'Hablante ') + speaker;\nconst srt = cues.map((cue, index) => (index + 1) + '\\n' + timestamp(cue.start, ',') + ' --> ' + timestamp(cue.end, ',') + '\\n' + label(cue.speaker) + ': ' + cue.text).join('\\n\\n') + '\\n';\nconst vtt = 'WEBVTT\\n\\n' + cues.map(cue => timestamp(cue.start, '.') + ' --> ' + timestamp(cue.end, '.') + '\\n' + label(cue.speaker) + ': ' + cue.text).join('\\n\\n') + '\\n';\nmsg.brainiallResult = { transcript: { text: clean(body.text) || join(words), words }, srt, vtt };\nmsg.payload = { success: true, statusCode, language, wordCount: words.length, speakerCount: speakerNumbers.size, durationSeconds: words[words.length - 1].end };\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1570,"y":170,"wires":[["c25debug"]]},{"id":"c25debug","type":"debug","z":"c25brainiallstt","name":"Metadata only — no path, key, media, or transcript","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1870,"y":170,"wires":[]}]

Flow Info

Created 2 days ago
Rating: not yet rated

Owner

Actions

Rate:

Node Types

Core
  • comment (x1)
  • debug (x1)
  • file in (x1)
  • function (x3)
  • http request (x1)
  • inject (x1)
Other
  • tab (x1)

Tags

  • transcription diarization speech-to-text pt-br portuguese spanish srt webvtt captions ai
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option