In short
LINE and Element each understand voice messages only in “their own” format – like two people who speak different languages. Until now, the LINE bridge only passed messages along without translating them. The patch adds the missing translator. Afterwards, voice messages work in both directions, and according to a practical test, video messages work too.
This post shows you step by step what you need to do and in which folder.
1. What was the problem?
- From Element to LINE: The voice message is fine in Element. In the LINE app, however, only a placeholder appears and it cannot be played.
- From LINE to Element: The message arrives and can be played, but it is displayed like an ordinary music file (“MP3”). The familiar voice message player is missing.
Why? It is neither your setup nor Matrix or Synapse. The cause lies in the bridge itself:
- Element records voice messages in a format that LINE cannot play. LINE uses a different format. The bridge simply passed the file on unchanged.
- In the opposite direction, the message lacked a marker that tells Element: “This is a voice message”. Without this marker, Element only shows the normal file player.
Lines containing op_type 43 sometimes show up in the log. They are harmless and only the echo of your own message.
Video: Matrix Server LINE Patch for the LINE Bridge
Language: 🇩🇪|🇬🇧
☝️ Use YouTube subtitles for all languages.
2. What the patch changes
A “patch” is a kind of correction list: a text file describes exactly which lines in which files are changed. You do not have to program anything by hand. This patch affects three files:
- audioconv.go (new): The translator. It uses the program ffmpeg, which is already built into the bridge, and converts audio into the matching format.
- audio.go (changed): Applies to messages from LINE to Element. The voice message is translated and given the “voice message” marker, the duration and the small waveform. If the translation fails, the original file arrives as before, so nothing is lost.
- send_message.go (changed): Applies to messages from Element to LINE. The voice message is translated into the LINE format before sending.
Matrix, Synapse, Element and your settings (config.yaml) remain untouched.
3. Before you start
💡 Data backup recommended: A short backup of your setup beforehand lets you lean back and relax.
You need:
- access to the matrix folder on your server (the file docker-compose.yml is located there)
- a terminal (console) on the server where the Docker containers run
4. What is where?
The starting point is the matrix folder containing the file docker-compose.yml.
- New file that you create yourself: matrix/line-bridge/line-audio.patch. You will find its content in the appendix at the end of this post.
- Files that the patch changes automatically (you do not have to open them), all in the folder matrix/line-bridge/matrix-line-messenger/pkg/connector/:
- handlers/audioconv.go (is newly created)
- handlers/audio.go (is changed)
- send_message.go (is changed)
5. Step by step
Step 1: Create the patch file. In the folder matrix/line-bridge/, create a new text file named line-audio.patch. Copy the complete content from the appendix into it without changing anything (spaces and indentation must stay exactly as they are). Save it as plain text, without an additional extension such as .txt.
Step 2: Switch to the source code folder. Open the terminal on the server and go to the matrix folder. From there:
cd line-bridge/matrix-line-messenger
Step 3: Dry run. This is only a test run, nothing is changed yet:
patch -p1 --dry-run < ../line-audio.patch
Only lines saying “checking file …” should appear. If it says “FAILED”, please do not continue, but check whether the content of the patch file was copied completely and unchanged.
Step 4: Apply the patch.
patch -p1 < ../line-audio.patch
Step 5 (recommended): Check that the translator knows both “languages”.
docker exec matrix-line-bridge ffmpeg -hide_banner -encoders | grep -E "libopus| aac "
Two lines must appear, one with libopus and one with aac.
Step 6: Rebuild and start the bridge. Back in the matrix folder, then:
cd ../..
docker compose build line-bridge
docker compose up -d line-bridge
Only the bridge is restarted. Synapse and Element keep running unchanged. If the build aborts with an error message, the bridge keeps running in the old version.
6. Testing
- Record a voice message in Element in a LINE chat and send it. It must be playable in the LINE app.
- Send a voice message from LINE. In Element it must appear in the familiar voice message player with a waveform.
- Send one video in each direction and play it.
Old messages do not change. Only new messages are translated.
7. Back to the old state
If you want to remove the patch again, in the folder matrix/line-bridge/matrix-line-messenger:
patch -R -p1 < ../line-audio.patch
cd ../..
docker compose build line-bridge && docker compose up -d line-bridge
8. Good to know
- The translator was tested with real voice messages. On the server, voice messages and, according to a practical test, video messages work. The long-term test is still running.
- Phone and video calls are shown by the bridge only as a notice message. The patch does not change that.
- If the bridge is updated to a newer version later, the patch must be applied again. Small adjustments may be necessary.
- The patch refers to the bridge version c40908e (22 Sep 2026).
Appendix: Content of the file line-audio.patch
Copy the following text completely and save it in matrix/line-bridge/line-audio.patch:
--- a/pkg/connector/handlers/audio.go
+++ b/pkg/connector/handlers/audio.go
@@ -99,14 +99,43 @@
}
}
- mxc, file, err := intent.UploadMedia(ctx, portal.MXID, audioData, "audio.m4a", "audio/mp4")
+ // LINE voice messages are AAC/M4A. Matrix clients (Element Web/X) only show the
+ // voice message player for Ogg/Opus with the MSC3245 voice marker, so convert.
+ // If anything fails, fall back to the original M4A file (previous behaviour).
+ uploadData, uploadName, uploadMime := audioData, "audio.m4a", "audio/mp4"
+ var extra map[string]any
+ if oggData, convErr := ConvertAudioToOggOpus(ctx, audioData); convErr != nil {
+ h.Log.Warn().Err(convErr).Str("msg_id", data.ID).
+ Msg("Failed to convert LINE audio to Ogg/Opus, sending original M4A")
+ } else {
+ uploadData, uploadName, uploadMime = oggData, "Voice message.ogg", "audio/ogg"
+
+ waveform, analyzedMs, anaErr := AnalyzeAudio(ctx, audioData, 100)
+ if anaErr != nil {
+ h.Log.Warn().Err(anaErr).Str("msg_id", data.ID).
+ Msg("Failed to compute waveform for LINE voice message")
+ }
+ if duration <= 0 {
+ duration = analyzedMs
+ }
+ audioBlock := map[string]any{"duration": duration}
+ if len(waveform) > 0 {
+ audioBlock["waveform"] = waveform
+ }
+ extra = map[string]any{
+ "org.matrix.msc3245.voice": map[string]any{},
+ "org.matrix.msc1767.audio": audioBlock,
+ }
+ }
+
+ mxc, file, err := intent.UploadMedia(ctx, portal.MXID, uploadData, uploadName, uploadMime)
if err != nil {
return nil, fmt.Errorf("failed to upload audio to matrix: %w", err)
}
audioInfo := &event.FileInfo{
- MimeType: "audio/mp4",
- Size: len(audioData),
+ MimeType: uploadMime,
+ Size: len(uploadData),
}
if duration > 0 {
audioInfo.Duration = duration
@@ -118,12 +147,13 @@
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgAudio,
- Body: "audio.m4a",
+ Body: uploadName,
URL: mxc,
File: file,
Info: audioInfo,
RelatesTo: relatesTo,
},
+ Extra: extra,
},
},
}, nil
--- a/pkg/connector/send_message.go
+++ b/pkg/connector/send_message.go
@@ -19,6 +19,7 @@
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
+ "github.com/highesttt/matrix-line-messenger/pkg/connector/handlers"
"github.com/highesttt/matrix-line-messenger/pkg/e2ee"
"github.com/highesttt/matrix-line-messenger/pkg/line"
"github.com/highesttt/matrix-line-messenger/pkg/ltsm"
@@ -524,6 +525,20 @@
return nil, fmt.Errorf("failed to download audio from matrix: %w", err)
}
+ // LINE's apps play voice messages as AAC/M4A. Element sends Ogg/Opus, which LINE
+ // shows only as an unplayable placeholder, so convert anything that isn't M4A yet.
+ if !handlers.IsMP4Audio(data) {
+ m4aData, convErr := handlers.ConvertAudioToM4A(ctx, data)
+ if convErr != nil {
+ return nil, fmt.Errorf("failed to convert audio to m4a for LINE: %w", convErr)
+ }
+ lc.UserLogin.Bridge.Log.Info().
+ Int("original_size", len(data)).
+ Int("converted_size", len(m4aData)).
+ Msg("Converted Matrix audio to M4A for LINE")
+ data = m4aData
+ }
+
contentType = int(ContentAudio)
contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data))
contentMetadata["contentType"] = fmt.Sprintf("%d", ContentAudio)
--- a/pkg/connector/handlers/audioconv.go
+++ b/pkg/connector/handlers/audioconv.go
@@ -0,0 +1,113 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "fmt"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+)
+
+const audioConvertTimeout = 90 * time.Second
+
+// ffmpegAudio writes data to a temp file, runs ffmpeg with the given output
+// arguments and returns the produced file. Temp files (instead of pipes) are
+// used on purpose: MP4/M4A needs seekable input and output.
+func ffmpegAudio(ctx context.Context, data []byte, outExt string, outArgs ...string) ([]byte, error) {
+ dir, err := os.MkdirTemp("", "line-audio-*")
+ if err != nil {
+ return nil, err
+ }
+ defer os.RemoveAll(dir)
+
+ in := filepath.Join(dir, "in.bin")
+ out := filepath.Join(dir, "out"+outExt)
+ if err = os.WriteFile(in, data, 0o600); err != nil {
+ return nil, err
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, audioConvertTimeout)
+ defer cancel()
+
+ args := []string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", in, "-vn"}
+ args = append(args, outArgs...)
+ args = append(args, out)
+
+ cmd := exec.CommandContext(ctx, "ffmpeg", args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err = cmd.Run(); err != nil {
+ return nil, fmt.Errorf("ffmpeg failed: %w: %s", err, stderr.String())
+ }
+ return os.ReadFile(out)
+}
+
+// ConvertAudioToM4A converts arbitrary audio (e.g. Ogg/Opus voice messages from
+// Matrix clients) to AAC-LC in an M4A container, mono 16 kHz. That is the same
+// format LINE's own apps use for voice messages.
+func ConvertAudioToM4A(ctx context.Context, data []byte) ([]byte, error) {
+ return ffmpegAudio(ctx, data, ".m4a",
+ "-ac", "1", "-ar", "16000", "-c:a", "aac", "-b:a", "32k",
+ "-movflags", "+faststart", "-f", "ipod")
+}
+
+// ConvertAudioToOggOpus converts audio (LINE voice messages are AAC/M4A) to
+// Ogg/Opus, the format required for Matrix voice messages (MSC3245).
+func ConvertAudioToOggOpus(ctx context.Context, data []byte) ([]byte, error) {
+ return ffmpegAudio(ctx, data, ".ogg",
+ "-ac", "1", "-ar", "48000", "-c:a", "libopus", "-b:a", "24k",
+ "-application", "voip", "-f", "ogg")
+}
+
+// AnalyzeAudio decodes the audio to 8 kHz mono PCM and returns a waveform with
+// the given number of buckets (values 0..1024, as used by MSC1767/MSC3246) and
+// the duration in milliseconds.
+func AnalyzeAudio(ctx context.Context, data []byte, buckets int) (waveform []int, durationMs int, err error) {
+ const rate = 8000
+ pcm, err := ffmpegAudio(ctx, data, ".pcm", "-ac", "1", "-ar", fmt.Sprint(rate), "-f", "s16le")
+ if err != nil {
+ return nil, 0, err
+ }
+ n := len(pcm) / 2
+ if n == 0 {
+ return nil, 0, fmt.Errorf("decoded audio is empty")
+ }
+ durationMs = n * 1000 / rate
+ if buckets <= 0 || buckets > n {
+ buckets = min(max(buckets, 1), n)
+ }
+
+ rms := make([]float64, buckets)
+ peak := 0.0
+ for b := 0; b < buckets; b++ {
+ from, to := b*n/buckets, (b+1)*n/buckets
+ if to <= from {
+ to = from + 1
+ }
+ var sum float64
+ for i := from; i < to; i++ {
+ s := float64(int16(binary.LittleEndian.Uint16(pcm[i*2:])))
+ sum += s * s
+ }
+ rms[b] = math.Sqrt(sum / float64(to-from))
+ peak = math.Max(peak, rms[b])
+ }
+
+ waveform = make([]int, buckets)
+ if peak > 0 {
+ for i, v := range rms {
+ waveform[i] = int(math.Round(v / peak * 1024))
+ }
+ }
+ return waveform, durationMs, nil
+}
+
+// IsMP4Audio reports whether data already looks like an MP4/M4A container
+// ("ftyp" box right at the start).
+func IsMP4Audio(data []byte) bool {
+ return len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp"))
+}
Sources

Support / Donation Link for the Channel
If my posts have been helpful or supported you in any way, I’d truly appreciate your support 🙏
#Matrix #LINE #Bridge #SelfHosting #Synapse