Core Subtitle Merging

This section provides examples of how to use the DuoSubs API for merging subtitles programmatically.

High-Level Subtitle Alignment

Here’s the most simplest way of merging two subtitles using the merging pipeline:

from duosubs import (DeviceType, MergeArgs, MergingMode, ModelPrecision,
                     OmitFile, SubtitleFormat, run_merge_pipeline)

# Store all arguments
args = MergeArgs(
    # Input Files (Required)
    primary="primary_sub.srt",      # primary subtitle file path
    secondary="secondary_sub.srt",  # secondary subtitle file path

    # Model & Inference (Optional)
    model="Qwen/Qwen3-Embedding-0.6B",    # SentenceTransformer model name, default: LaBSE
    device=DeviceType.CPU,      # Device to run the model on: AUTO (default), CPU, CPU, or MPS
    batch_size=128,             # batch size for model inference, any positive integer, default: 32
    model_precision=ModelPrecision.FLOAT16, # precision mode for model inference: FLOAT32 (default), FLOAT16, or BFLOAT16

    # Alignment Behavior (Optional)
    merging_mode=MergingMode.CUTS,      #  Mode for merging subtitles: SYNCED (default), MIXED, CUTS

    # Output Styling (Optional)
    retain_newline=False,       # whether to retain "\N" line breaks in output: True (default) or False
    secondary_above=False,      # whether to show secondary subtitle above primary: True (default) or False

    # Output Files (Optional)
    omit=[
        # OmitFile.EDIT,        # omit edit file, if you want to omit it, uncomment this line
        # OmitFile.PRIMARY,     # omit primary file, if you want to omit it, uncomment this line
        # OmitFile.SECONDARY,   # omit secondary file, if you want to omit it, uncomment this line
        # OmitFile.COMBINED,    # omit combined file, if you want to omit it, uncomment this line
        OmitFile.NONE           # do not omit any files, you can remove this line if you want to keep all files
    ],  # Default: [OmitFile.EDIT]

    # The following arguments accept SubtitleFormat enum values: SRT, VTT, MPL, TTML, ASS, SSA
    format_all=SubtitleFormat.VTT,       # file format for all subtitle outputs, default: ASS
    format_combined=None,                # file format for combined subtitle output, default: None
    format_primary=None,                 # file format for primary subtitle output, default: None
    format_secondary=SubtitleFormat.SSA, # file format for secondary subtitle output, default: None

    output_name="processed_sub",         # base name for output files (without extension), default: primary subtitle name
    output_dir=None     # output directory for generated files, default: primary subtitle location
)

# Load, merge, and save subtitles, all inside the pipeline
run_merge_pipeline(args, print)

Just create a duosubs.MergeArgs instance, tweak the settings needed, and pass it to duosubs.run_merge_pipeline(). It’ll generate processed_sub.zip containing the subtitle files based on the specified omit settings.

Modular Pipeline Usage

For more flexibility, you can use the modular pipeline, i.e.

The following code lets you to add extra steps like pre- or post-processing of subtitles between duosubs.merge_subtitles().

import logging
from typing import Any, Callable

from tqdm import tqdm

from duosubs import (LoadModelError, LoadSubsError, MergeArgs, MergeSubsError,
                     SaveSubsError, load_sentence_transformer_model,
                     load_subtitles, merge_subtitles, save_subtitles_in_zip)

# Store all arguments
args = MergeArgs(
    # Input Files (Required)
    primary="primary_sub.srt",      # primary subtitle file path
    secondary="secondary_sub.srt",  # secondary subtitle file path

    # For other options, please refer to the High-Level Subtitle Alignment.
)

def make_progress_callback(progress_bar: Any) -> Callable[[float], None]:
    """
    Creates a callback function to update the progress bar statically, instead
    of incrementally.
    """
    last_percent: list[float] = [0.0]

    def callback(current_percent: float) -> None:
        delta = current_percent - last_percent[0]
        if delta > 0:
            progress_bar.update(delta)
            last_percent[0] = current_percent

    return callback

try:
    # 1. Load both subtitles
    primary_subs_data, secondary_subs_data = load_subtitles(
        args,
        lambda: print("Stage 1 → Loading subtitles") # Status logger
    )

    # 2. Load the Sentence Transformer model for inference
    model = load_sentence_transformer_model(
        args,
        lambda model_name, device:
        print(f"Stage 2 → Loading {model_name} on {device.upper()}") # Status logger
    )

    # 3. You can prepocess the subtitles here, like further filtering the subtitles.

    # 4. Merge the subtitles
    with tqdm(
        total=100,
        desc= "Stage 3 → Merging subtitles",
        bar_format="{l_bar}{bar}| [{elapsed}<{remaining}, {rate_fmt}{postfix}]"
    ) as pbar:
        callback = make_progress_callback(pbar)
        merged_subs = merge_subtitles(
            args,
            model,
            primary_subs_data,
            secondary_subs_data,
            [False],    # stop_bit, can be used to stop the merging process early
            progress_callback=callback # Note: the progress is incrementally updated inside the function.
        )

    # 5. Post processing the merged subtitles can be done here, like changing styles, etc.

    # 6. Save the merged subtitles in a zip file
    save_subtitles_in_zip(
        args,
        merged_subs,
        primary_subs_data.styles,
        secondary_subs_data.styles,
        lambda output_name:
        print(f"Stage 4 → Saving files to {output_name}.zip") # Status logger
    )

    print("Status  → Subtitles merged and saved successfully.")

except LoadSubsError as e1:
    logging.error(str(e1), exc_info=True)
except LoadModelError as e2:
    logging.error(str(e2), exc_info=True)
except MergeSubsError as e3:
    logging.error(str(e3), exc_info=True)
except SaveSubsError as e4:
    logging.error(str(e4), exc_info=True)

Under-the-Hood Merging API

You can customize the merging process by using the core algorithm directly from the class duosubs.Merger.

This allows you to implement your own logic around the merging process.

The following code merges subtitles in duosubs.MergingMode.SYNCED mode, where all the timestamps of both subtitles overlap.

from typing import Any, Callable

from tqdm import tqdm

from duosubs import (Merger, MergeArgs,
                     load_sentence_transformer_model,
                     load_subtitles, save_subtitles_in_zip)

args = MergeArgs(
    primary="primary_sub.srt",
    secondary="secondary_sub.srt"
)

primary_subs_data, secondary_subs_data = load_subtitles(
    args,
    lambda: print("Stage 1 → Loading subtitles")
)

model = load_sentence_transformer_model(
    args,
    lambda model_name, device:
    print(f"Stage 2 → Loading {model_name} on {device.upper()}")
)

def make_progress_callback(progress_bar: Any) -> Callable[[float], None]:
    last_percent: list[float] = [0.0]

    def callback(current_percent: float) -> None:
        delta = current_percent - last_percent[0]
        if delta > 0:
            progress_bar.update(delta)
            last_percent[0] = current_percent

    return callback

# Merging the subtitles
merger = Merger(primary_subs_data, secondary_subs_data)
stop_bit = [False] # You can create a function to stop the following merging process early.

with tqdm(
    total=100,
    desc= "Stage 3 → Merging subtitles",
    bar_format="{l_bar}{bar}| [{elapsed}<{remaining}, {rate_fmt}{postfix}]"
) as pbar:
    # If you insert any additional steps between the merging process,
    # do not use the progress_callback function.
    progress_callback = make_progress_callback(pbar)

    # 1. Extract and filter non-overlapping subs
    (
        non_overlap_primary_subs,
        non_overlap_secondary_subs
    ) = merger.extract_non_overlapping_subs(stop_bit, progress_callback)

    # 2. Estimate tokenized subtitle pairings using DTW
    processed_subs = merger.align_subs_with_dtw(
        model,
        stop_bit,
        args.batch_size,
        progress_callback
    )

    # 3. Refine alignment using a sliding window approach
    stage_number = 0
    window_sizes = [3, 2]
    for window_size in window_sizes:
        processed_subs, stage_number = merger.align_subs_using_neighbours(
            processed_subs,
            window_size,
            model,
            stage_number,
            stop_bit,
            args.batch_size,
            progress_callback
        )

    # 4. Combine aligned and non-overlapping subtitles
    processed_subs.extend(non_overlap_primary_subs)
    processed_subs.extend(non_overlap_secondary_subs)
    processed_subs.sort()

    # 5. Clean up unnecessary newlines in subtitle text fields.
    processed_subs = merger.eliminate_unnecessary_newline(
        processed_subs,
        stop_bit,
        progress_callback
    )

# The 5 merging steps above are encapsulated in the following high-level function.
# To use the simplified version, comment out the steps above and uncomment the line below:
#    processed_subs = merger.merge_subtitle(
#        model,
#        stop_bit,
#        args.ignore_non_overlap_filter,
#        args.batch_size,
#        progress_callback
#    )

save_subtitles_in_zip(
    args,
    processed_subs,
    primary_subs_data.styles,
    secondary_subs_data.styles,
    lambda output_name:
    print(f"Stage 4 → Saving files to {output_name}.zip")
)

print("Status  → Subtitles merged and saved successfully.")

The following code illustrates the underlying logic of duosubs.MergingMode.CUTS mode, in which the primary subtitles represent the extended version, while the secondary subtitles provide the shorter version.

from typing import Any, Callable

from tqdm import tqdm

from duosubs import (Merger, MergeArgs,
                    load_sentence_transformer_model,
                    load_subtitles, save_subtitles_in_zip)

args = MergeArgs(
    primary="primary_sub.srt",
    secondary="secondary_sub.srt"
)

primary_subs_data, secondary_subs_data = load_subtitles(
    args,
    lambda: print("Stage 1 → Loading subtitles")
)

model = load_sentence_transformer_model(
    args,
    lambda model_name, device:
    print(f"Stage 2 → Loading {model_name} on {device.upper()}")
)

def make_progress_callback(progress_bar: Any) -> Callable[[float], None]:
    last_percent: list[float] = [0.0]

    def callback(current_percent: float) -> None:
        delta = current_percent - last_percent[0]
        if delta > 0:
            progress_bar.update(delta)
            last_percent[0] = current_percent

    return callback

# Merging the subtitles
merger = Merger(primary_subs_data, secondary_subs_data)
stop_bit = [False] # You can create a function to stop the following merging process early.

with tqdm(
    total=100,
    desc= "Stage 3 → Merging subtitles",
    bar_format="{l_bar}{bar}| [{elapsed}<{remaining}, {rate_fmt}{postfix}]"
) as pbar:
    # If you insert any additional steps between the merging process,
    # do not use the progress_callback function.
    progress_callback = make_progress_callback(pbar)

    # Reset the ratio of extract non overlapping subs to 0
    # since this process does not require to use merger.extract_non_overlapping_subs
    merger._ratio_extract_non_overlapping_subs = 0

    # 1. Estimate tokenized subtitle pairings using DTW
    processed_subs = merger.align_subs_with_dtw(
        model,
        stop_bit,
        args.batch_size,
        progress_callback
    )

    # 2. Refine alignment using a sliding window approach
    stage_number = 0
    processed_subs, stage_number = merger.align_subs_using_neighbours(
        processed_subs,
        3,
        model,
        stage_number,
        stop_bit,
        args.batch_size,
        progress_callback
    )

    merger._ratio_filter_and_extract_extended_version = 0.02
    # 3. Filter and extract the extended segments from the primary subtitle
    (
        processed_subs,
        non_overlap_primary_subs
    ) = merger.filter_and_extract_extended_version(
        processed_subs,
        model,
        stop_bit,
        args.batch_size,
        progress_callback
    )

    # 4. Further refine alignment using a sliding window approach
    processed_subs, _ = merger.align_subs_using_neighbours(
        processed_subs,
        2,
        model,
        stage_number,
        stop_bit,
        args.batch_size,
        progress_callback
    )

    # 5. Combine aligned and non-overlapping subtitles
    processed_subs.extend(non_overlap_primary_subs)
    processed_subs.sort()

    # 6. Clean up unnecessary newlines in subtitle text fields.
    processed_subs = merger.eliminate_unnecessary_newline(
        processed_subs,
        stop_bit,
        progress_callback
    )

# The 6 merging steps above are encapsulated in the following high-level function.
# To use the simplified version, comment out the steps above and uncomment the line below:
#    processed_subs = merger.merge_subtitle_extended_cut(
#        model,
#        stop_bit,
#        args.batch_size,
#        progress_callback
#    )

save_subtitles_in_zip(
    args,
    processed_subs,
    primary_subs_data.styles,
    secondary_subs_data.styles,
    lambda output_name:
    print(f"Stage 4 → Saving files to {output_name}.zip")
)

print("Status  → Subtitles merged and saved successfully.")

Low-Level Subtitle I/O API

Subtitle File Loading Utilities

If you only need to load a single subtitle file, use duosubs.load_subs() instead of duosubs.load_subtitles().

It returns a duosubs.SubtitleData instance that includes:

from duosubs import load_subs

subs_data = load_subs("primary_sub.srt")

To load an edit file (with a .json.gz extension) generated by this tool for internal use, use the duosubs.load_file_edit() function.

It returns list of duosubs.SubtitleField along with both primary and secondary style information.

from duosubs import load_file_edit

subs_data = load_file_edit("sub_edit.json.gz")

Subtitle File Writing Utilities

If you prefer to save the files separately instead of as a single ZIP archive, you can use the following approach.

There are two ways of saving the subtitle files:

Below is an example of saving subtitles to disk. Each function can also be used independently:

from pathlib import Path

import pysubs2

from duosubs import (SubtitleField, save_file_combined, save_file_edit,
                    save_file_separate)

merged_subs = [
    SubtitleField(
        start=0,
        end=1000,
        primary_text="Hello!",
        secondary_text="Bonjour!"
    )
]   # Assume this is a list containing subtitle fields after merging process
primary_styles = pysubs2.SSAFile()      # Suppose this contains the primary style
secondary_styles = pysubs2.SSAFile()    # Suppose this contains the secondary style

path = Path("D:/Users/Name/Documents/Folder")

# Saves both merged subtitles into a single file
save_file_combined(
    merged_subs,
    primary_styles,
    secondary_styles,
    save_path = path / "sub_combined.ass",
    secondary_above = False,
    retain_newline = False
)

# Saves the primary and secondary subtitle files separately
save_file_separate(
    merged_subs,
    primary_styles,
    secondary_styles,
    save_path_primary = path / "sub_primary.ass",
    save_path_secondary = path / "sub_secondary.ass",
    retain_newline=False
)

# Saves the list of SubtitleFields along with primary and secondary style information to a compressed file.
# Intended for internal use only.
save_file_edit(
    merged_subs,
    primary_styles,
    secondary_styles,
    save_path = path / "sub_edit.json"
)

Alternatively, you can save the merged subtitles to memory for further processing. Similarly, each function supports separate usage:

import pysubs2

from duosubs import (SubtitleField, save_memory_combined, save_memory_edit,
                    save_memory_separate)

merged_subs = [
    SubtitleField(
        start=0,
        end=1000,
        primary_text="Hello!",
        secondary_text="Bonjour!"
    )
]   # Assume this is a list containing subtitle fields after merging process
primary_styles = pysubs2.SSAFile()      # Suppose this contains the primary style
secondary_styles = pysubs2.SSAFile()    # Suppose this contains the secondary style

# Saves both merged subtitles into bytes
combined_subs_bytes = save_memory_combined(
    merged_subs,
    primary_styles,
    secondary_styles,
    extension_fmt = "srt",
    secondary_above = False,
    retain_newline = False
)

# Saves the primary and secondary subtitle into two separate bytes
primary_subs_bytes, secondary_subs_bytes = save_memory_separate(
    merged_subs,
    primary_styles,
    secondary_styles,
    extension_primary = "ass",
    extension_secondary = "ass",
    retain_newline=False
)

# Saves the list of SubtitleFields along with primary and secondary style information to a compressed bytes.
# Intended for internal use only.
edit_subs_bytes = save_memory_edit(
    merged_subs,
    primary_styles,
    secondary_styles
)