With Grandstream’s UCM Products there is one feature that is lacking and in demand. Speech to text in voicemail is almost everywhere these days but not on the UCM so this is what I did to make it work.
I start with a container in my Proxmox enviroment.
I give my container 8 cpus and 8 Gb of memory and 80 Gb of storage. Using ubuntu 24.04-standard After creating my container I will start it and login the console First Edit the sshd_config file
nano /etc/ssh/sshd_config
Unmake PermitRootLogin and set value to yes
systemctl restart sshd
Now I can login through putty.
Now download the script below or here https://www.accessit.cloud/Voicemailtext.sh
Before running the script you will want to add port forwarding if your server is behind a firewall. Forward ports 80, 443 and your desired app port like 5678. After running the script you can close ports 80 and 443.
Voicemail-to-Text Transcription Script Summary
This script leverages OpenAI’s Whisper automatic speech recognition (ASR) system to convert voicemail audio files into accurate, readable text. It is designed to run on a local machine or server, automatically downloading the required Whisper model (in this case, the small model) if not already present. By using Whisper’s robust multilingual speech-to-text capabilities and FFmpeg for audio handling, the script provides a reliable and efficient transcription solution.
Key Features
- Automatic Model Download
- On first run, the script calls
whisper.load_model("small")
, which downloads and caches thesmall
model (~461 MB) in~/.cache/whisper/
. - This ensures the model is available offline for subsequent transcriptions without repeated downloads.
- On first run, the script calls
- Audio File Handling
- Uses FFmpeg (an open-source media processing tool) to decode voicemail audio formats (MP3, WAV, M4A, etc.).
- Supports various sample rates and codecs, making it suitable for different voicemail systems.
- Speech-to-Text Conversion
- Processes the voicemail audio and outputs a text transcription.
- Whisper’s neural network handles noise, accents, and variable quality recordings effectively.
- Scalability
- The script can be integrated into larger workflows (e.g., email parsing, CRM ticket creation, or voicemail notifications).
- Model size can be adjusted (
tiny
,base
,small
,medium
,large
) based on desired speed vs. accuracy.
How It Works
- Initialization:
- Checks for and downloads the Whisper
small
model. - Verifies FFmpeg is available for audio processing.
- Checks for and downloads the Whisper
- Input:
- Takes a voicemail audio file path as input (e.g.,
voicemail.mp3
).
- Takes a voicemail audio file path as input (e.g.,
- Processing:
- Audio is loaded and preprocessed using FFmpeg.
- The Whisper model converts the speech into text.
- Output:
- Prints or saves the transcription for further automation, such as attaching it to an email or storing it in a ticketing system.
Script for installing n8n, whisper, nginx, certbot
#!/bin/bash
# ========================================
# n8n + Whisper + Nginx + Certbot Setup Script
# Ubuntu 24.04 LTS
# Created By Ryan Fisher ryan@accessitnet.com
# ========================================
set -e
# Ask for domain and webhook port
read -p "Enter the domain for n8n (e.g., sub.domain.loc): " DOMAIN
read -p "Enter the HTTPS port for n8n (e.g., 5678): " N8N_PORT
WEBHOOK_URL="https://$DOMAIN:$N8N_PORT"
# -------------------------------
# Update system
# -------------------------------
echo "Updating system..."
sudo apt update && sudo apt upgrade -y
# -------------------------------
# Install dependencies
# -------------------------------
echo "Installing dependencies..."
sudo apt install -y curl build-essential python3-venv python3-pip nginx certbot python3-certbot-nginx ufw
# -------------------------------
# Install Node.js 20.x globally
# -------------------------------
echo "Installing Node.js 20.x..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
echo "Node version: $(node -v)"
echo "NPM version: $(npm -v)"
# -------------------------------
# Install n8n globally
# -------------------------------
echo "Installing n8n..."
sudo npm install -g n8n
# -------------------------------
# Create n8n user and folders
# -------------------------------
echo "Creating n8n user and folders..."
sudo useradd -m -s /bin/bash n8n || true
sudo mkdir -p /home/n8n/.n8n
sudo chown -R n8n:n8n /home/n8n
# -------------------------------
# Set webhook URL for n8n
# -------------------------------
echo "Setting webhook URL..."
sudo bash -c "echo 'export N8N_WEBHOOK_URL=$WEBHOOK_URL' >> /home/n8n/.bashrc"
# -------------------------------
# Create systemd service for n8n
# -------------------------------
echo "Creating systemd service..."
cat <<EOF | sudo tee /etc/systemd/system/n8n.service
[Unit]
Description=n8n automation
After=network.target
[Service]
Type=simple
User=n8n
Environment=HOME=/home/n8n
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=N8N_WEBHOOK_URL=$WEBHOOK_URL
ExecStart=/usr/bin/n8n start
Restart=always
RestartSec=10
LimitNOFILE=65535
WorkingDirectory=/home/n8n
[Install]
WantedBy=multi-user.target
EOF
echo "Creating .env file..."
cat <<EOF | sudo tee /home/n8n/.env
N8N_HOST=localhost
N8N_PORT=5678
N8N_PROTOCOL=http
N8N_EDITOR_BASE_URL=$WEBHOOK_URL
EOF
sudo chown n8n:n8n /home/n8n/.env
sudo chmod 600 /home/n8n/.env
sudo systemctl daemon-reload
sudo systemctl enable n8n
sudo systemctl start n8n
# -------------------------------
# Setup Nginx reverse proxy on custom port
# -------------------------------
echo "Setting up Nginx reverse proxy on port $N8N_PORT..."
cat <<EOF | sudo tee /etc/nginx/sites-available/n8n
server {
listen 80;
server_name $DOMAIN;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
# -------------------------------
# Allow custom HTTPS port in firewall
# -------------------------------
sudo ufw allow $N8N_PORT/tcp
sudo ufw reload
# -------------------------------
# Setup SSL with Certbot
# -------------------------------
echo "Setting up SSL with Certbot..."
sudo certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m admin@$DOMAIN
# -------------------------------
# Install Whisper in Python venv
# -------------------------------
echo "Installing Whisper in virtual environment..."
apt install python3.12-venv -y
sudo -u n8n bash <<EOF
cd /home/n8n
python3 -m venv whisper
source whisper/bin/activate
pip install --upgrade pip setuptools wheel
pip install openai-whisper
deactivate
EOF
sudo apt install ffmpeg -y
# Download Small Model
/home/n8n/whisper/bin/python3 -m whisper /dev/null --model small
# -------------------------------
# Restart n8n service
# -------------------------------
sudo systemctl restart n8n
echo "====================================================="
echo "✅ Setup complete!"
echo "n8n URL: https://$DOMAIN:$N8N_PORT"
echo "Webhook URL: $WEBHOOK_URL"
echo "Whisper installed in /home/n8n/whisper"
echo "====================================================="
This script provides a lightweight, automated, and accurate voicemail-to-text workflow that can be expanded with additional automation, making it ideal for customer service, IT support, or unified communications environments.
N8N Setup
The Script pulls unread emails with attachments you can also filter the sender. The concept is in your UCM you will set the extensions email address to an alias of office.useremail@domain.loc These aliases should be setup on the account you will use below to authenticate with office 365. The workflow will strip the office. from the email and use the rest as the end user email.
After getting your server up and running you will need to open your app url https://app.domain.loc:5678
It will ask your to setup and email and password and ask some usage questions.
Once on the dashboard click on the 3 dots right of the Save button and click on Import from URL.
Enter https://www.accessit.cloud/VoicemailText.json and then click Import.
The n8n template will Office 365 credentials setup.
Open the Outlook Trigger Node.
Under Credential to connect with select + Create New Credential
Now open Office 365 Admin Centers and then open Identity
On the Left menu open App registrations
Then click + New registration
Give your App a name
Under Redirect URI select Web as your platform and enter the return auth url found back in n8n
It will look like https://app.domain.loc:5678/rest/oauth2-credential/callback
Next Click Register
Now copy your Application (client) ID and add it to N8N under Client ID
Next Click on Add a certificate or secret.
Then + New client secret, give the secret a name and set the expiration then click Add
Copy the secret value and past in N8N under Client Secret
Go back to Office 365 and under Mange click on Authentication
Then on the page scroll down to Supported account types and select Multitenant and click Save
Then in N8N click on Connect my account. A window will open for you to enter your admin credential for office 365. Give permissions and Consent then click on Accept.
Now it should say in green Account connected. Close the N8N window.
Back on the dashboard set the credentials for Send Forwared Email node and Mark As Read Node
Now Open the GetMessageHeader node and click on the edit pin right of the OAuth2 API.
Enter your client ID and Client Secret from before
Under Scope add: https://graph.microsoft.com/Mail.Read offline_access
Authentication Select Body then click on Save.
To change how the alias will be open the Extract Username node and modify the code for your setup.
Voicemails sent to the register email account will now be read by n8n and forward the email to the user with text extracted from the email wav file.
Also make sure to change from inactive to Active to cycle the workflow every minute.
N8N Code
{
“name”: “VoicemailText”,
“nodes”: [
{
“parameters”: {
“pollTimes”: {
“item”: [
{
“mode”: “everyMinute”
}
]
},
“output”: “raw”,
“filters”: {
“hasAttachments”: true,
“readStatus”: “unread”,
“sender”: “sender@domain.loc”
},
“options”: {
“downloadAttachments”: true
}
},
“id”: “e1af37ef-edd5-431d-84e8-7d2479465e20”,
“name”: “Outlook Trigger”,
“type”: “n8n-nodes-base.microsoftOutlookTrigger”,
“typeVersion”: 1,
“position”: [
-304,
256
],
“credentials”: {
“microsoftOutlookOAuth2Api”: {
“id”: “xZfLfmsoCiP5ZtX1”,
“name”: “Microsoft Outlook account”
}
}
},
{
“parameters”: {
“fileName”: “=/home/n8n/{{ $json.id }}.wav”,
“dataPropertyName”: “attachment_0”,
“options”: {}
},
“id”: “94529679-ac5c-4cbe-8fd0-1ee0bf0726f4”,
“name”: “Save WAV File”,
“type”: “n8n-nodes-base.writeBinaryFile”,
“typeVersion”: 1,
“position”: [
0,
0
]
},
{
“parameters”: {
“command”: “=/home/n8n/whisper/bin/python3 -m whisper /home/n8n/{{ $json.id }}.wav –model small –language en –output_format txt –output_dir /home/n8n/”
},
“id”: “d2158a74-d7c8-4d50-afa5-316a613f05f3”,
“name”: “Run Whisper”,
“type”: “n8n-nodes-base.executeCommand”,
“typeVersion”: 1,
“position”: [
112,
0
]
},
{
“parameters”: {
“filePath”: “=/home/n8n/{{ $(‘Outlook Trigger’).item.json.id }}.txt”,
“dataPropertyName”: “textdata”
},
“id”: “b9c1a290-7f3d-473e-afa5-3d87dad4d43e”,
“name”: “Read WAV File”,
“type”: “n8n-nodes-base.readBinaryFile”,
“typeVersion”: 1,
“position”: [
224,
0
]
},
{
“parameters”: {
“operation”: “text”,
“binaryPropertyName”: “textdata”,
“options”: {}
},
“id”: “b590b9ea-57d3-4236-9bb4-58162d74348c”,
“name”: “Extract from File”,
“type”: “n8n-nodes-base.extractFromFile”,
“typeVersion”: 1,
“position”: [
336,
0
]
},
{
“parameters”: {
“functionCode”: “const headers = $json[\”internetMessageHeaders\”] || [];\nlet rawAddress = null;\n\n// Try to find the actual header containing the email\nfor (const h of headers) {\n const name = h.name.toLowerCase();\n if (name === \”delivered-to\” || name === \”to\” || name === \”x-ms-exchange-crosstenant-userprincipalname\”) {\n rawAddress = h.value;\n break;\n }\n}\n\nif (!rawAddress) {\n return [{ aliasAddress: \”unknown@domain.com\” }];\n}\n\n// Some headers might be Base64-encoded or contain extra characters\ntry {\n if (/^[A-Za-z0-9+/=]+$/.test(rawAddress)) {\n rawAddress = Buffer.from(rawAddress, \”base64\”).toString(\”utf8\”);\n }\n} catch (err) {\n // Not base64, ignore\n}\n\n// Clean the address:\n// 1. Remove \”office.\” prefix if present\n// 2. Remove any surrounding < >\n// 3. Trim whitespace\nlet cleanedAddress = rawAddress\n .replace(/office\./i, \”\”)\n .replace(/[<>]/g, \”\”)\n .trim();\n\n// Special case mapping\nif (cleanedAddress.toLowerCase() === \”admin@domain.loc\”) {\n cleanedAddress = \”mygmail@gmail.com\”;\n}\n\n// Return both raw and cleaned\nreturn [{ \n fullAddress: rawAddress,\n aliasAddress: cleanedAddress \n}];”
},
“id”: “66005d28-4106-46a2-803c-159414bd5469”,
“name”: “Extract Username”,
“type”: “n8n-nodes-base.function”,
“typeVersion”: 1,
“position”: [
160,
160
],
“alwaysOutputData”: false
},
{
“parameters”: {
“mode”: “combine”,
“combineBy”: “combineByPosition”,
“numberInputs”: 3,
“options”: {}
},
“id”: “90ddfb33-83ce-4e61-863c-50108dff9239”,
“name”: “Merge”,
“type”: “n8n-nodes-base.merge”,
“typeVersion”: 3.1,
“position”: [
656,
208
]
},
{
“parameters”: {
“subject”: “=Audio Transcription {{ $(‘Outlook Trigger’).item.json.subject }}”,
“bodyContent”: “={{ $(‘Outlook Trigger’).item.json.bodyPreview }} Audio Transcription: {{ $(‘Extract from File’).item.json.data }}”,
“toRecipients”: “={{ $(‘Extract Username’).item.json.aliasAddress }}”,
“additionalFields”: {
“attachments”: {
“attachments”: [
{
“binaryPropertyName”: “attachment_0”
}
]
}
}
},
“id”: “edf1bea1-82b1-4f2e-a2bb-4159059c73fa”,
“name”: “Send Forwarded Email”,
“type”: “n8n-nodes-base.microsoftOutlook”,
“typeVersion”: 1,
“position”: [
800,
224
],
“credentials”: {
“microsoftOutlookOAuth2Api”: {
“id”: “xZfLfmsoCiP5ZtX1”,
“name”: “Microsoft Outlook account”
}
}
},
{
“parameters”: {
“operation”: “update”,
“messageId”: “={{ $(‘Outlook Trigger’).item.json.id }}”,
“updateFields”: {
“isRead”: true
}
},
“id”: “b352cf84-7724-4f9a-a1f8-0aa4ba83050a”,
“name”: “Mark As Read”,
“type”: “n8n-nodes-base.microsoftOutlook”,
“typeVersion”: 1,
“position”: [
960,
224
],
“credentials”: {
“microsoftOutlookOAuth2Api”: {
“id”: “xZfLfmsoCiP5ZtX1”,
“name”: “Microsoft Outlook account”
}
}
},
{
“parameters”: {
“url”: “=https://graph.microsoft.com/v1.0/me/messages/{{$json[\”id\”]}}?$select=internetMessageHeaders”,
“authentication”: “genericCredentialType”,
“genericAuthType”: “oAuth2Api”,
“options”: {}
},
“type”: “n8n-nodes-base.httpRequest”,
“typeVersion”: 4.2,
“position”: [
0,
160
],
“id”: “5b4b978b-470d-489f-8ff6-082e3b32e648”,
“name”: “GetMessageHeader”,
“credentials”: {
“oAuth2Api”: {
“id”: “MsgYJcgDytmLPZW7”,
“name”: “Unnamed credential”
}
}
},
{
“parameters”: {
“jsCode”: “// Get the original HTML from Outlook Trigger\nlet outlookBody = $(‘Outlook Trigger’).first().json.body || {};\nlet html = outlookBody.content || \”\”; // use .content if it exists\n\n// Get transcription text from previous node (Extract from File)\nlet transcription = $input.first().json.data || \”\”;\n\n// Insert transcription after \”folder.\”\nif (html.includes(\”folder.\”)) {\n html = html.replace(\”folder.\”, folder.</div><p><strong>Message Transcription:</strong> ${transcription}</p>
);\n} else {\n // fallback if marker not found\n html += <p><strong>Message Transcription:</strong> ${transcription}</p>
;\n}\n\n// Return as HTML object so Send node knows it’s HTML\nreturn [{\n body: {\n contentType: \”HTML\”,\n content: html\n }\n}];”
},
“type”: “n8n-nodes-base.code”,
“typeVersion”: 2,
“position”: [
448,
0
],
“id”: “04607995-1b5c-425e-b549-5937f6fedafd”,
“name”: “Insert Text”
}
],
“pinData”: {},
“connections”: {
“Outlook Trigger”: {
“main”: [
[
{
“node”: “Save WAV File”,
“type”: “main”,
“index”: 0
},
{
“node”: “Merge”,
“type”: “main”,
“index”: 2
},
{
“node”: “GetMessageHeader”,
“type”: “main”,
“index”: 0
}
]
]
},
“Save WAV File”: {
“main”: [
[
{
“node”: “Run Whisper”,
“type”: “main”,
“index”: 0
}
]
]
},
“Run Whisper”: {
“main”: [
[
{
“node”: “Read WAV File”,
“type”: “main”,
“index”: 0
}
]
]
},
“Read WAV File”: {
“main”: [
[
{
“node”: “Extract from File”,
“type”: “main”,
“index”: 0
}
]
]
},
“Extract from File”: {
“main”: [
[
{
“node”: “Insert Text”,
“type”: “main”,
“index”: 0
}
]
]
},
“Extract Username”: {
“main”: [
[
{
“node”: “Merge”,
“type”: “main”,
“index”: 1
}
]
]
},
“Merge”: {
“main”: [
[
{
“node”: “Send Forwarded Email”,
“type”: “main”,
“index”: 0
}
]
]
},
“Send Forwarded Email”: {
“main”: [
[
{
“node”: “Mark As Read”,
“type”: “main”,
“index”: 0
}
]
]
},
“GetMessageHeader”: {
“main”: [
[
{
“node”: “Extract Username”,
“type”: “main”,
“index”: 0
}
]
]
},
“Insert Text”: {
“main”: [
[
{
“node”: “Merge”,
“type”: “main”,
“index”: 0
}
]
]
}
},
“active”: false,
“settings”: {
“executionOrder”: “v1”
},
“versionId”: “aff8527e-a206-42b5-98e5-ecb7bf1ad0f6”,
“meta”: {
“templateCredsSetupCompleted”: true,
“instanceId”: “54da3079586e1792707f7314ed9a0485d0bc9e7deadbc29dbf7fdb0b9ef9a379”
},
“id”: “UFlsZuhmfIrIg4fi”,
“tags”: []
}