9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

リアルタイム文字起こしでBGMを誤認識させない技術的アイデア:複数マイク活用は有効か?

Last updated at Posted at 2025-12-24

この記事はLITALICO Engineers Advent Calendar 2025 カレンダー3 の 6日目の記事です

はじめに

リアルタイム音声文字起こしシステムを構築していると、BGM(背景音楽)を人間の声として強引に文字起こししてしまう問題に直面することがあります。特にBGMに歌声が含まれている場合、音声認識エンジンは容赦なくそれを「発話」として解釈してしまいます。

本記事では、この問題に対して「こうすれば防げるのではないか」という技術的なアイデアを検討します。複数マイクを活用した空間的判別、音響特徴による帯域フィルタリング、歌声特有のパターン検出など、実装可能性のあるアプローチを考察していきます。

問題の本質:なぜBGMが文字起こしされるのか

音声認識エンジンは、音声の「意味」を理解しているわけではありません。音響的に「人間の発話に似た特徴」を持つ音であれば、それが意図的な発話かBGMかを区別できないと考えられます。

特に以下のような場合に誤認識が発生しやすくなります

  • カフェやレストランでのBGM
  • オンライン会議中に流れる参加者側のBGM
  • プレゼン動画のBGMトラック
  • 歌声を含む音楽(ボーカル入り楽曲)

アイデア1:複数マイクによる空間的判別

基本的な考え方

人間の発話とBGMの決定的な違いは音源の位置にあるのではないでしょうか

  • 人間の発話:特定のマイク(話者に最も近いマイク)に強く入力されるはず
  • BGM:全てのマイクにほぼ均等に入力される(スピーカーからの拡散音)

この特性を利用すれば、複数マイクの入力レベル差からBGMを判別できる可能性があります。

実装案:2マイクシステム

以下のような実装が考えられます

import numpy as np
from scipy import signal
import pyaudio

class MultiMicBGMDetector:
    def __init__(self, threshold_ratio=3.0):
        """
        Args:
            threshold_ratio: マイク間の音量比のしきい値
                           この値より小さい場合、BGMと判定
        """
        self.threshold_ratio = threshold_ratio
        self.sample_rate = 16000
        self.chunk_size = 1024
        
    def calculate_rms(self, audio_data):
        """RMS(二乗平均平方根)を計算"""
        return np.sqrt(np.mean(np.square(audio_data)))
    
    def is_human_speech(self, mic1_data, mic2_data):
        """
        2つのマイク入力から人間の発話かBGMかを判定
        
        Returns:
            bool: True=人間の発話、False=BGM
        """
        rms1 = self.calculate_rms(mic1_data)
        rms2 = self.calculate_rms(mic2_data)
        
        # 両方が静寂の場合はスキップ
        if rms1 < 0.01 and rms2 < 0.01:
            return False
        
        # 音量比を計算(大きい方 / 小さい方)
        max_rms = max(rms1, rms2)
        min_rms = min(rms1, rms2) + 1e-10  # ゼロ除算回避
        volume_ratio = max_rms / min_rms
        
        # 比率が大きければ人間の発話(特定のマイクに集中)
        # 比率が小さければBGM(全マイクに均等)
        return volume_ratio > self.threshold_ratio
    
    def get_mic_correlation(self, mic1_data, mic2_data):
        """
        2つのマイク間の相関係数を計算
        BGMは高い相関(0.8以上)、人間の発話は低い相関を示す傾向
        """
        correlation = np.corrcoef(mic1_data, mic2_data)[0, 1]
        return correlation

# 使用例
detector = MultiMicBGMDetector(threshold_ratio=3.0)

# PyAudioで2つのマイクから入力を取得(擬似コード)
mic1_stream = pyaudio.PyAudio().open(format=pyaudio.paInt16, 
                                      channels=1, rate=16000,
                                      input=True, input_device_index=0)
mic2_stream = pyaudio.PyAudio().open(format=pyaudio.paInt16,
                                      channels=1, rate=16000,
                                      input=True, input_device_index=1)

while True:
    mic1_data = np.frombuffer(mic1_stream.read(1024), dtype=np.int16)
    mic2_data = np.frombuffer(mic2_stream.read(1024), dtype=np.int16)
    
    if detector.is_human_speech(mic1_data, mic2_data):
        # 文字起こしエンジンに送信
        print("人間の発話を検出")
    else:
        # BGMと判定、スキップ
        print("BGM検出、文字起こしをスキップ")

検討すべきポイント

  1. マイク配置:2つのマイクを話者の位置から等距離かつ十分離して配置する必要がありそうです(50cm以上が良いかもしれません)
  2. キャリブレーション:環境に応じてthreshold_ratioを調整する必要があるでしょう(静かな環境では3.0、騒音環境では2.0程度が目安になりそうです)
  3. 相関係数の併用:音量比だけでなく、波形の相関も見ることでより高精度に判別できるのではないでしょうか

アイデア2:周波数帯域による判別

人間の発話とBGMの周波数特性の違い

周波数特性に着目すると、以下のような違いがあるのではないでしょうか

  • 人間の発話:主に300Hz〜3400Hz(基本周波数は男性80-180Hz、女性165-255Hz)
  • BGM(特にベース・ドラム):50Hz〜200Hzの低域が強いことが多い
  • BGM(シンセ・ハイハット):8kHz以上の高域成分が多い傾向がある

バンドパスフィルタによる前処理案

この周波数特性の違いを利用すれば、以下のようなフィルタリングが実装できそうです

import numpy as np
from scipy.signal import butter, sosfilt, welch

class FrequencyBasedBGMFilter:
    def __init__(self, sample_rate=16000):
        self.sample_rate = sample_rate
        # 人間の発話に最適化したバンドパスフィルタ
        self.sos = butter(4, [300, 3400], btype='band', 
                         fs=sample_rate, output='sos')
        
    def apply_bandpass(self, audio_data):
        """バンドパスフィルタを適用"""
        return sosfilt(self.sos, audio_data)
    
    def calculate_spectral_flatness(self, audio_data):
        """
        スペクトル平坦度を計算
        BGMは多くの周波数成分を持つため高い値(0.3以上)
        人間の発話は特定の周波数に集中するため低い値(0.1以下)
        """
        freqs, psd = welch(audio_data, fs=self.sample_rate, 
                          nperseg=1024)
        
        # 幾何平均 / 算術平均
        geometric_mean = np.exp(np.mean(np.log(psd + 1e-10)))
        arithmetic_mean = np.mean(psd)
        
        spectral_flatness = geometric_mean / (arithmetic_mean + 1e-10)
        return spectral_flatness
    
    def detect_low_frequency_energy(self, audio_data):
        """
        低周波数帯域(50-200Hz)のエネルギー比率を計算
        BGMのベース音を検出
        """
        freqs, psd = welch(audio_data, fs=self.sample_rate, nperseg=1024)
        
        # 50-200Hzのエネルギー
        low_freq_mask = (freqs >= 50) & (freqs <= 200)
        low_freq_energy = np.sum(psd[low_freq_mask])
        
        # 全体エネルギー
        total_energy = np.sum(psd)
        
        return low_freq_energy / (total_energy + 1e-10)
    
    def is_likely_bgm(self, audio_data):
        """
        周波数特性からBGMかどうかを判定
        """
        spectral_flatness = self.calculate_spectral_flatness(audio_data)
        low_freq_ratio = self.detect_low_frequency_energy(audio_data)
        
        # BGMの判定基準
        # 1. スペクトル平坦度が高い(周波数成分が広範囲)
        # 2. 低周波数エネルギーが多い(ベース音)
        is_bgm = (spectral_flatness > 0.25) or (low_freq_ratio > 0.3)
        
        return is_bgm

# 使用例
freq_filter = FrequencyBasedBGMFilter()

def preprocess_audio_for_transcription(audio_data):
    """文字起こし前の音声前処理"""
    # BGM判定
    if freq_filter.is_likely_bgm(audio_data):
        return None  # 文字起こしをスキップ
    
    # 人間の発話域にバンドパスフィルタ適用
    filtered_audio = freq_filter.apply_bandpass(audio_data)
    
    return filtered_audio

周波数分析の可視化

import matplotlib.pyplot as plt

def visualize_spectrum(audio_data, sample_rate=16000, title="Spectrum"):
    """スペクトルを可視化してBGMか発話かを視覚的に確認"""
    freqs, psd = welch(audio_data, fs=sample_rate, nperseg=1024)
    
    plt.figure(figsize=(10, 4))
    plt.semilogy(freqs, psd)
    plt.xlabel('Frequency (Hz)')
    plt.ylabel('Power Spectral Density')
    plt.title(title)
    plt.axvline(300, color='r', linestyle='--', label='Speech Band Start')
    plt.axvline(3400, color='r', linestyle='--', label='Speech Band End')
    plt.legend()
    plt.grid(True)
    plt.show()

# 人間の発話とBGMの比較
visualize_spectrum(speech_audio, title="Human Speech")
visualize_spectrum(bgm_audio, title="Background Music")

アイデア3:歌声を含むBGMへの対策

歌声は人間の発話と音響的に非常に似ているため、最も誤認識しやすい要素です。しかし、以下のような違いに着目すれば判別できる可能性があります。

歌声と会話の違い

  • ピッチの安定性:通常の会話よりも音高が安定している傾向がある
  • ビブラート:周期的な音高の揺らぎ(5-7Hz程度)が存在することがある
  • 音の長さ:1音節あたりの持続時間が会話より長いことが多い
  • ダイナミクス:音量変化が音楽的(突然の変化が少ない)

歌声検出の実装案

これらの特徴を利用すれば、以下のようなアルゴリズムで歌声を検出できるかもしれません

import librosa

class SingingVoiceDetector:
    def __init__(self, sample_rate=16000):
        self.sample_rate = sample_rate
        
    def detect_pitch_stability(self, audio_data):
        """
        ピッチの安定性を計算
        歌声は安定したピッチを保つ傾向がある
        """
        # ピッチ検出
        pitches, magnitudes = librosa.piptrack(
            y=audio_data.astype(float), 
            sr=self.sample_rate
        )
        
        # 各フレームの主要ピッチを抽出
        pitch_values = []
        for t in range(pitches.shape[1]):
            index = magnitudes[:, t].argmax()
            pitch = pitches[index, t]
            if pitch > 0:
                pitch_values.append(pitch)
        
        if len(pitch_values) < 10:
            return 0.0
        
        # ピッチの標準偏差を計算(低いほど安定)
        pitch_std = np.std(pitch_values)
        mean_pitch = np.mean(pitch_values)
        
        # 変動係数(CV)で正規化
        cv = pitch_std / (mean_pitch + 1e-10)
        
        # CVが0.05以下なら非常に安定(歌声の可能性大)
        return cv
    
    def detect_vibrato(self, audio_data):
        """
        ビブラートの存在を検出
        5-7Hzの周期的なピッチ変動
        """
        pitches, _ = librosa.piptrack(
            y=audio_data.astype(float),
            sr=self.sample_rate
        )
        
        # ピッチ軌跡の自己相関を計算
        # ビブラートがあれば5-7Hzに対応するラグでピークが出る
        # (実装は簡略化のため省略)
        
        return False  # 詳細実装が必要な場合は追加
    
    def detect_note_duration(self, audio_data):
        """
        音符の持続時間を分析
        歌声は1音が長い(0.3秒以上)
        """
        # オンセット検出
        onset_frames = librosa.onset.onset_detect(
            y=audio_data.astype(float),
            sr=self.sample_rate
        )
        
        if len(onset_frames) < 2:
            return 0.0
        
        # オンセット間隔を計算
        onset_intervals = np.diff(onset_frames) / self.sample_rate * 1024
        mean_interval = np.mean(onset_intervals)
        
        return mean_interval
    
    def is_singing_voice(self, audio_data):
        """
        歌声かどうかを総合判定
        """
        pitch_cv = self.detect_pitch_stability(audio_data)
        note_duration = self.detect_note_duration(audio_data)
        
        # 判定基準
        # 1. ピッチが安定している(CV < 0.1)
        # 2. 音符の持続が長い(> 0.25秒)
        is_singing = (pitch_cv < 0.1) and (note_duration > 0.25)
        
        return is_singing

# 使用例
singing_detector = SingingVoiceDetector()

def filter_singing_from_transcription(audio_data):
    """歌声を含むBGMをフィルタリング"""
    if singing_detector.is_singing_voice(audio_data):
        print("歌声を検出:文字起こしをスキップ")
        return None
    
    return audio_data

統合案:複数アプローチの組み合わせ

実際のシステムでは、これらの手法を組み合わせることでより高い精度が期待できるのではないでしょうか。

class ComprehensiveBGMFilter:
    def __init__(self, num_mics=2):
        self.multi_mic_detector = MultiMicBGMDetector(threshold_ratio=3.0)
        self.freq_filter = FrequencyBasedBGMFilter()
        self.singing_detector = SingingVoiceDetector()
        self.num_mics = num_mics
        
    def should_transcribe(self, audio_data_list):
        """
        複数の判定基準を統合して文字起こしの可否を判断
        
        Args:
            audio_data_list: 各マイクからの音声データのリスト
            
        Returns:
            (bool, str): (文字起こし可否, 判定理由)
        """
        # ステップ1:複数マイクによる空間判定
        if self.num_mics >= 2:
            if not self.multi_mic_detector.is_human_speech(
                audio_data_list[0], audio_data_list[1]
            ):
                return False, "空間分布がBGMパターン"
        
        # ステップ2:周波数特性による判定
        primary_audio = audio_data_list[0]
        if self.freq_filter.is_likely_bgm(primary_audio):
            return False, "周波数特性がBGM"
        
        # ステップ3:歌声検出
        if self.singing_detector.is_singing_voice(primary_audio):
            return False, "歌声を検出"
        
        # ステップ4:前処理してOK
        return True, "人間の発話と判定"
    
    def get_filtered_audio(self, audio_data):
        """
        文字起こし用に最適化された音声データを返す
        """
        # バンドパスフィルタ適用
        filtered = self.freq_filter.apply_bandpass(audio_data)
        return filtered

# 使用例
bgm_filter = ComprehensiveBGMFilter(num_mics=2)

def realtime_transcription_pipeline(mic_data_list, transcription_engine):
    """
    リアルタイム文字起こしパイプライン
    """
    should_transcribe, reason = bgm_filter.should_transcribe(mic_data_list)
    
    if not should_transcribe:
        print(f"スキップ: {reason}")
        return None
    
    # 主マイクの音声を前処理
    filtered_audio = bgm_filter.get_filtered_audio(mic_data_list[0])
    
    # 文字起こし実行
    result = transcription_engine.transcribe(filtered_audio)
    
    return result

実装する場合の考慮点

1. レイテンシの問題

リアルタイム処理では、分析処理が遅延の原因になる可能性があります

# 悪い例:すべてのフレームで重い処理
for frame in audio_stream:
    if singing_detector.is_singing_voice(frame):  # 毎回librosa処理
        continue

# 良い例:軽い判定を先に実行
for frame in audio_stream:
    # まず軽いRMS判定
    if calculate_rms(frame) < threshold:
        continue
    
    # 次に中程度の判定
    if freq_filter.is_likely_bgm(frame):
        continue
    
    # 最後に重い判定(必要な場合のみ)
    if singing_detector.is_singing_voice(frame):
        continue

2. しきい値のチューニング

環境に応じて各種しきい値を調整する必要がありそうです

# 環境プロファイル
ENVIRONMENTS = {
    'quiet_office': {
        'volume_ratio_threshold': 3.5,
        'spectral_flatness_threshold': 0.2,
        'rms_threshold': 0.02
    },
    'noisy_cafe': {
        'volume_ratio_threshold': 2.0,
        'spectral_flatness_threshold': 0.35,
        'rms_threshold': 0.05
    },
    'conference_room': {
        'volume_ratio_threshold': 3.0,
        'spectral_flatness_threshold': 0.25,
        'rms_threshold': 0.03
    }
}

def create_filter_for_environment(env_type):
    params = ENVIRONMENTS.get(env_type, ENVIRONMENTS['quiet_office'])
    # パラメータを使用してフィルタを初期化
    return ComprehensiveBGMFilter(**params)

3. ログと評価の仕組み

誤判定を減らすため、判定結果をログに残して継続的に改善する仕組みが必要になるでしょう

import json
from datetime import datetime

class BGMFilterLogger:
    def __init__(self, log_file='bgm_filter_log.jsonl'):
        self.log_file = log_file
        
    def log_decision(self, audio_features, decision, reason, 
                     transcription_result=None):
        """判定結果をログ"""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'features': {
                'rms': audio_features.get('rms'),
                'spectral_flatness': audio_features.get('spectral_flatness'),
                'volume_ratio': audio_features.get('volume_ratio'),
                'pitch_cv': audio_features.get('pitch_cv')
            },
            'decision': decision,
            'reason': reason,
            'transcription': transcription_result
        }
        
        with open(self.log_file, 'a') as f:
            f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
    
    def analyze_false_positives(self):
        """誤検出(BGMを発話と判定)を分析"""
        # ログからパターンを分析してしきい値を最適化
        pass

クラウド音声認識サービスとの統合

Google Cloud Speech-to-Textの場合

from google.cloud import speech

def transcribe_with_bgm_filter(audio_data_list):
    """
    Google Speech-to-Textと統合した例
    """
    bgm_filter = ComprehensiveBGMFilter(num_mics=len(audio_data_list))
    
    # BGMフィルタリング
    should_transcribe, reason = bgm_filter.should_transcribe(audio_data_list)
    if not should_transcribe:
        return None
    
    # 音声の前処理
    filtered_audio = bgm_filter.get_filtered_audio(audio_data_list[0])
    
    # Google Speech-to-Text設定
    client = speech.SpeechClient()
    config = speech.RecognitionConfig(
        encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
        sample_rate_hertz=16000,
        language_code="ja-JP",
        # ノイズ抑制を有効化
        enable_automatic_punctuation=True,
        use_enhanced=True,
        model="latest_long"
    )
    
    audio = speech.RecognitionAudio(content=filtered_audio.tobytes())
    response = client.recognize(config=config, audio=audio)
    
    return response.results

OpenAI Whisperの場合

import whisper

def transcribe_with_whisper_and_filter(audio_data_list):
    """
    Whisperと統合した例
    """
    bgm_filter = ComprehensiveBGMFilter(num_mics=len(audio_data_list))
    
    should_transcribe, reason = bgm_filter.should_transcribe(audio_data_list)
    if not should_transcribe:
        return {"text": "", "skipped": True, "reason": reason}
    
    # Whisperモデル
    model = whisper.load_model("base")
    
    # 前処理
    filtered_audio = bgm_filter.get_filtered_audio(audio_data_list[0])
    
    # Whisperのオプションでさらに制御
    result = model.transcribe(
        filtered_audio,
        language="ja",
        # VAD(Voice Activity Detection)のしきい値を厳しく
        vad_threshold=0.5,
        # 短すぎるセグメントを無視
        min_silence_duration_ms=500
    )
    
    return result

パフォーマンス最適化

NumPy演算の高速化

# 悪い例:Pythonループ
def slow_rms_calculation(audio_data):
    sum_sq = 0
    for sample in audio_data:
        sum_sq += sample ** 2
    return (sum_sq / len(audio_data)) ** 0.5

# 良い例:NumPyベクトル演算
def fast_rms_calculation(audio_data):
    return np.sqrt(np.mean(np.square(audio_data)))

# さらに良い例:NumPy linalg
def fastest_rms_calculation(audio_data):
    return np.linalg.norm(audio_data) / np.sqrt(len(audio_data))

マルチスレッド処理

from concurrent.futures import ThreadPoolExecutor
import queue

class RealtimeTranscriptionSystem:
    def __init__(self, num_mics=2):
        self.bgm_filter = ComprehensiveBGMFilter(num_mics=num_mics)
        self.audio_queue = queue.Queue(maxsize=100)
        self.result_queue = queue.Queue()
        self.executor = ThreadPoolExecutor(max_workers=3)
        
    def audio_capture_thread(self, mic_streams):
        """音声キャプチャスレッド"""
        while True:
            audio_chunks = [stream.read(1024) for stream in mic_streams]
            self.audio_queue.put(audio_chunks)
    
    def processing_thread(self):
        """BGMフィルタリングスレッド"""
        while True:
            audio_chunks = self.audio_queue.get()
            
            # NumPy配列に変換
            audio_arrays = [
                np.frombuffer(chunk, dtype=np.int16) 
                for chunk in audio_chunks
            ]
            
            # フィルタリング
            should_transcribe, reason = self.bgm_filter.should_transcribe(
                audio_arrays
            )
            
            if should_transcribe:
                filtered = self.bgm_filter.get_filtered_audio(audio_arrays[0])
                self.result_queue.put(filtered)
    
    def transcription_thread(self, transcription_engine):
        """文字起こしスレッド"""
        while True:
            audio_data = self.result_queue.get()
            result = transcription_engine.transcribe(audio_data)
            print(f"文字起こし結果: {result}")
    
    def start(self, mic_streams, transcription_engine):
        """全スレッドを開始"""
        self.executor.submit(self.audio_capture_thread, mic_streams)
        self.executor.submit(self.processing_thread)
        self.executor.submit(self.transcription_thread, transcription_engine)

期待される効果と課題

想定されるシナリオ

これらのアプローチを実装した場合、以下のようなシナリオで効果が期待できそうです

シナリオ 期待される動作 課題・懸念点
カフェBGM(インスト) 正しくスキップできそう 音量が小さい場合は判定が難しいかも
カフェBGM(ボーカル入り) 歌声検出で識別できる可能性 ラップなど会話に近い歌は難しいかも
会議中の発話 正しく文字起こしできるはず 複数人が同時に話すと難しいかも
プレゼン動画のBGM スペクトル特性で判別できそう BGMと発話が混在する場合は課題
離れた場所の会話 要検証 誤ってスキップする可能性あり

評価指標の考え方

実装した場合、以下のような評価を行うことになるでしょう

def evaluate_filter_performance(test_data):
    """
    フィルタの性能評価(実装した場合の評価イメージ)
    
    test_data: [{'audio': array, 'label': 'speech'|'bgm', 'mics': [array]}]
    """
    true_positives = 0   # 発話を発話と判定
    true_negatives = 0   # BGMをBGMと判定
    false_positives = 0  # BGMを発話と誤判定
    false_negatives = 0  # 発話をBGMと誤判定
    
    bgm_filter = ComprehensiveBGMFilter(num_mics=2)
    
    for sample in test_data:
        should_transcribe, _ = bgm_filter.should_transcribe(sample['mics'])
        
        if sample['label'] == 'speech':
            if should_transcribe:
                true_positives += 1
            else:
                false_negatives += 1
        else:  # BGM
            if should_transcribe:
                false_positives += 1
            else:
                true_negatives += 1
    
    # 精度指標の計算
    precision = true_positives / (true_positives + false_positives + 1e-10)
    recall = true_positives / (true_positives + false_negatives + 1e-10)
    f1_score = 2 * precision * recall / (precision + recall + 1e-10)
    
    print(f"Precision: {precision:.3f}")
    print(f"Recall: {recall:.3f}")
    print(f"F1 Score: {f1_score:.3f}")
    
    return {
        'precision': precision,
        'recall': recall,
        'f1': f1_score
    }

まとめ

リアルタイム文字起こしにおけるBGM誤認識を防ぐアイデアとして、以下の3つのアプローチを検討しました

  1. 複数マイクによる空間的判別:音源の位置情報を活用する方法
  2. 周波数帯域フィルタリング:人間の発話域に特化した前処理
  3. 歌声検出:音楽的特徴(ピッチ安定性、音符の長さ)による判別

実装する場合の考慮点

  • 💡 軽い判定から順に実行してレイテンシを最小化する工夫が必要
  • 💡 環境に応じてしきい値をチューニングする必要がある
  • 💡 ログを残して継続的に改善する仕組みが重要
  • 💡 マルチスレッド処理でリアルタイム性を確保できるかもしれない

これらのアプローチを組み合わせることで、BGMによる誤認識を大幅に削減しつつ、人間の発話は確実に文字起こしできるシステムが実現できる可能性があります。実際の効果は実装して検証する必要がありますが、技術的には実現可能なアプローチではないでしょうか。

参考文献

  • Librosa: Audio and Music Signal Analysis in Python
  • SciPy Signal Processing Documentation
  • Google Cloud Speech-to-Text Best Practices
  • OpenAI Whisper Documentation

サンプルコード

本記事のサンプルコードは実装の参考として提供しています。実際のプロダクション環境での使用には、十分なテストと調整が必要です。

9
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?