ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
2914 lines
102 KiB
Python
Executable File
2914 lines
102 KiB
Python
Executable File
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import argparse
|
|
import atexit
|
|
import base64
|
|
import configparser
|
|
import csv
|
|
import datetime
|
|
import difflib
|
|
import io
|
|
import json
|
|
import logging
|
|
import math
|
|
import operator
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
import types
|
|
from collections import defaultdict
|
|
from os.path import expanduser
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
import dash
|
|
import dash_bootstrap_components as dbc
|
|
import diff_match_patch
|
|
import editdistance
|
|
import jiwer
|
|
import numpy as np
|
|
import pandas as pd
|
|
import soundfile as sf
|
|
import tqdm
|
|
from dash import dash_table, dcc, html
|
|
from dash.dependencies import Input, Output, State
|
|
from dash.exceptions import PreventUpdate
|
|
from kaldialign import edit_distance
|
|
from plotly import express as px
|
|
from plotly import graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
|
|
|
|
def _ensure_numba_coverage_compatibility():
|
|
"""Patch coverage API differences that break older numba imports."""
|
|
try:
|
|
import coverage
|
|
except ImportError:
|
|
return
|
|
|
|
try:
|
|
coverage_types = coverage.types
|
|
except AttributeError:
|
|
try:
|
|
import coverage.types as coverage_types
|
|
except ImportError:
|
|
coverage_types = types.SimpleNamespace()
|
|
coverage.types = coverage_types
|
|
|
|
if coverage_types is None:
|
|
return
|
|
|
|
if not hasattr(coverage_types, 'Tracer'):
|
|
tracer_type = getattr(coverage_types, 'TTracer', object)
|
|
if not isinstance(tracer_type, type):
|
|
tracer_type = object
|
|
coverage_types.Tracer = tracer_type
|
|
|
|
for type_name in (
|
|
'TTraceData',
|
|
'TShouldTraceFn',
|
|
'TFileDisposition',
|
|
'TShouldStartContextFn',
|
|
'TWarnFn',
|
|
'TTraceFn',
|
|
):
|
|
if not hasattr(coverage_types, type_name):
|
|
setattr(coverage_types, type_name, object)
|
|
|
|
|
|
# Keep this immediately before importing librosa; librosa can import numba,
|
|
# and older numba releases expect coverage.types.Tracer to exist.
|
|
_ensure_numba_coverage_compatibility()
|
|
import librosa # noqa: E402
|
|
|
|
# S3/cloud dependencies — only required when using --s3cfg
|
|
try:
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
|
|
_S3_AVAILABLE = True
|
|
except ImportError:
|
|
boto3 = None
|
|
Config = None
|
|
|
|
class ClientError(Exception):
|
|
pass
|
|
|
|
_S3_AVAILABLE = False
|
|
|
|
# Optional dependency for sharded _OP_/_CL_ expansion. A local fallback is used
|
|
# when braceexpand is unavailable.
|
|
try:
|
|
import braceexpand
|
|
|
|
_BRACEEXPAND_AVAILABLE = True
|
|
except ImportError:
|
|
braceexpand = None
|
|
_BRACEEXPAND_AVAILABLE = False
|
|
|
|
# Configure logging to show INFO level messages
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
|
# number of items in a table per page
|
|
DATA_PAGE_SIZE = 10
|
|
# key in the manifest file that contains the text
|
|
TEXT_KEY = 'text'
|
|
|
|
# Global S3 client (initialized lazily)
|
|
_s3_client = None
|
|
|
|
|
|
def parse_s3cfg(config_path='~/.s3cfg', section='default'):
|
|
"""
|
|
Parse the .s3cfg file and extract configuration values.
|
|
|
|
Args:
|
|
config_path: Path to the s3cfg file (default: ~/.s3cfg)
|
|
section: Section of the config file to parse (default: default)
|
|
|
|
Returns:
|
|
dict: Dictionary containing the parsed configuration
|
|
"""
|
|
# Expand user path
|
|
config_path = Path(config_path).expanduser()
|
|
|
|
# Check if file exists
|
|
if not config_path.exists():
|
|
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
|
|
# Create ConfigParser instance
|
|
config = configparser.ConfigParser()
|
|
|
|
# Read the config file
|
|
config.read(config_path)
|
|
|
|
# Extract values from [default] section
|
|
if section in config:
|
|
s3_config = {
|
|
'use_https': config.getboolean(section, 'use_https', fallback=True),
|
|
'access_key': config.get(section, 'access_key', fallback=None),
|
|
'secret_key': config.get(section, 'secret_key', fallback=None),
|
|
'bucket_location': config.get(section, 'bucket_location', fallback=None),
|
|
'host_base': config.get(section, 'host_base', fallback=None),
|
|
'authn_token': config.get(section, 'authn_token', fallback=None),
|
|
}
|
|
return s3_config
|
|
else:
|
|
raise ValueError(f"No [{section}] section found in config file")
|
|
|
|
|
|
class AISClient:
|
|
"""Thin S3-compatible client for AIStore using Bearer token auth."""
|
|
|
|
def __init__(self, endpoint_url, token):
|
|
import requests as _requests
|
|
|
|
self._base = endpoint_url.rstrip('/')
|
|
self._session = _requests.Session()
|
|
self._session.headers['Authorization'] = f'Bearer {token}'
|
|
|
|
def get_object(self, Bucket, Key, Range=None):
|
|
url = f'{self._base}/s3/{Bucket}/{Key}'
|
|
headers = {}
|
|
if Range:
|
|
headers['Range'] = Range
|
|
resp = self._session.get(url, headers=headers)
|
|
if resp.status_code >= 400:
|
|
raise ClientError(
|
|
{'Error': {'Code': str(resp.status_code), 'Message': resp.reason}},
|
|
'GetObject',
|
|
)
|
|
return {'Body': io.BytesIO(resp.content)}
|
|
|
|
|
|
def get_s3_client(s3cfg):
|
|
"""Get or create an S3-compatible client.
|
|
Args:
|
|
s3cfg: S3 configuration file path with section (e.g. ~/.s3cfg[default]),
|
|
or the literal string "AIS" to read credentials from AIS_ENDPOINT
|
|
and AIS_AUTHN_TOKEN environment variables instead of a config file.
|
|
Returns:
|
|
boto3.client or AISClient
|
|
"""
|
|
if not _S3_AVAILABLE:
|
|
raise ImportError("S3 support requires 'boto3' and 'botocore'. " "Install with: pip install boto3")
|
|
global _s3_client
|
|
if _s3_client is not None:
|
|
return _s3_client
|
|
|
|
if s3cfg == 'AIS':
|
|
endpoint_url = os.environ.get('AIS_ENDPOINT')
|
|
authn_token = os.environ.get('AIS_AUTHN_TOKEN')
|
|
missing = [n for n, v in (('AIS_ENDPOINT', endpoint_url), ('AIS_AUTHN_TOKEN', authn_token)) if not v]
|
|
if missing:
|
|
raise ValueError(f"--s3cfg=AIS requires environment variables: {', '.join(missing)} not set")
|
|
_s3_client = AISClient(endpoint_url, authn_token)
|
|
return _s3_client
|
|
|
|
if '[' not in s3cfg:
|
|
raise ValueError(f"--s3cfg value must include a section in brackets, e.g. ~/.s3cfg[default]. Got: {s3cfg}")
|
|
path, section = s3cfg.rsplit('[', 1)
|
|
s3_config = parse_s3cfg(path, section.rstrip(']'))
|
|
# NOTE: logs credentials at DEBUG level — only the tool operator can enable
|
|
# DEBUG, but avoid persisting debug logs to shared storage.
|
|
logging.debug(f"S3 config loaded: {s3_config}")
|
|
if not s3_config.get('host_base'):
|
|
raise ValueError(
|
|
f"'host_base' is missing or empty in [{section.rstrip(']')}] section of {path}. "
|
|
"Set it to the S3 endpoint hostname (e.g. s3.amazonaws.com)."
|
|
)
|
|
endpoint_url = ("https://" if s3_config['use_https'] else "http://") + s3_config['host_base']
|
|
authn_token = s3_config.get('authn_token')
|
|
|
|
if authn_token:
|
|
_s3_client = AISClient(endpoint_url, authn_token)
|
|
else:
|
|
_s3_client = boto3.client(
|
|
's3',
|
|
endpoint_url=endpoint_url,
|
|
aws_access_key_id=s3_config['access_key'],
|
|
aws_secret_access_key=s3_config['secret_key'],
|
|
region_name=s3_config['bucket_location'],
|
|
config=Config(connect_timeout=5),
|
|
)
|
|
return _s3_client
|
|
|
|
|
|
def is_s3_path(path):
|
|
"""Check if a path is an S3 URL."""
|
|
if path is None:
|
|
return False
|
|
return str(path).startswith('s3://')
|
|
|
|
|
|
def is_sharded_path(path):
|
|
"""Check if a path contains a sharded range pattern like _OP_0..255_CL_."""
|
|
if path is None:
|
|
return False
|
|
return '_OP_' in str(path) and '_CL_' in str(path)
|
|
|
|
|
|
def expand_sharded_path(path_pattern):
|
|
"""Expand a sharded path pattern into a list of individual paths.
|
|
|
|
Converts NeMo _OP_/_CL_ range syntax to brace syntax and uses braceexpand,
|
|
when available, for correct cartesian-product expansion of multiple ranges.
|
|
|
|
Supports patterns like:
|
|
s3://ASR/manifest__OP_0..255_CL_.json
|
|
-> [manifest_0.json, manifest_1.json, ..., manifest_255.json]
|
|
|
|
s3://ASR/bucket_OP_1..2_CL_/audio__OP_0..1_CL_.tar
|
|
-> [bucket1/audio_0.tar, bucket1/audio_1.tar,
|
|
bucket2/audio_0.tar, bucket2/audio_1.tar]
|
|
|
|
Args:
|
|
path_pattern: Path string containing _OP_start..end_CL_ pattern(s)
|
|
|
|
Returns:
|
|
list: List of expanded paths, or single-element list if no pattern found
|
|
"""
|
|
s = str(path_pattern)
|
|
if '_OP_' not in s:
|
|
return [s]
|
|
if not _BRACEEXPAND_AVAILABLE:
|
|
return expand_sharded_path_without_braceexpand(s)
|
|
brace_pattern = s.replace('_OP_', '{').replace('_CL_', '}')
|
|
return list(braceexpand.braceexpand(brace_pattern))
|
|
|
|
|
|
def expand_sharded_path_without_braceexpand(path_pattern):
|
|
"""Expand NeMo _OP_start..end_CL_ ranges without external dependencies."""
|
|
op = path_pattern.find('_OP_')
|
|
if op == -1:
|
|
return [path_pattern]
|
|
|
|
cl = path_pattern.find('_CL_', op)
|
|
if cl == -1:
|
|
raise ValueError(f"Malformed sharded path pattern, missing _CL_: {path_pattern}")
|
|
|
|
range_expr = path_pattern[op + len('_OP_') : cl]
|
|
if '..' not in range_expr:
|
|
raise ValueError(f"Malformed sharded path range, expected start..end: {path_pattern}")
|
|
|
|
start_str, end_str = range_expr.split('..', 1)
|
|
start = int(start_str)
|
|
end = int(end_str)
|
|
step = 1 if end >= start else -1
|
|
width = max(len(start_str.lstrip('-')), len(end_str.lstrip('-')))
|
|
zero_pad = width > 1 and (start_str.lstrip('-').startswith('0') or end_str.lstrip('-').startswith('0'))
|
|
|
|
prefix = path_pattern[:op]
|
|
suffix = path_pattern[cl + len('_CL_') :]
|
|
expanded = []
|
|
for value in range(start, end + step, step):
|
|
if zero_pad:
|
|
sign = '-' if value < 0 else ''
|
|
value_str = f"{sign}{abs(value):0{width}d}"
|
|
else:
|
|
value_str = str(value)
|
|
expanded.extend(expand_sharded_path_without_braceexpand(prefix + value_str + suffix))
|
|
return expanded
|
|
|
|
|
|
def parse_s3_path(s3_path):
|
|
"""Parse an S3 URL into bucket and key components.
|
|
|
|
Args:
|
|
s3_path: S3 URL in format s3://bucket/key
|
|
|
|
Returns:
|
|
tuple: (bucket, key)
|
|
"""
|
|
parsed = urlparse(str(s3_path))
|
|
bucket = parsed.netloc
|
|
key = parsed.path.lstrip('/')
|
|
return bucket, key
|
|
|
|
|
|
def require_s3_client(s3_path):
|
|
"""Return the configured S3 client or fail with an actionable message."""
|
|
if _s3_client is None:
|
|
raise RuntimeError(
|
|
f"S3 path requires S3 configuration: {s3_path}. "
|
|
"Run with --s3cfg ~/.s3cfg[default] when using s3:// paths, "
|
|
"or use --s3cfg AIS with AIS_ENDPOINT and AIS_AUTHN_TOKEN set."
|
|
)
|
|
return _s3_client
|
|
|
|
|
|
def read_s3_file(s3_path):
|
|
"""Read a file from S3 and return its contents as a string.
|
|
|
|
Args:
|
|
s3_path: S3 URL to the file
|
|
Returns:
|
|
str: File contents
|
|
"""
|
|
try:
|
|
bucket, key = parse_s3_path(s3_path)
|
|
s3_client = require_s3_client(s3_path)
|
|
response = s3_client.get_object(Bucket=bucket, Key=key)
|
|
return response['Body'].read().decode('utf-8')
|
|
except ClientError as e:
|
|
logging.error(f"Error reading S3 file {s3_path}: {e}")
|
|
raise
|
|
|
|
|
|
def read_s3_file_bytes(s3_path):
|
|
"""Read a file from S3 and return its contents as bytes.
|
|
|
|
Args:
|
|
s3_path: S3 URL to the file
|
|
|
|
Returns:
|
|
bytes: File contents
|
|
"""
|
|
try:
|
|
bucket, key = parse_s3_path(s3_path)
|
|
s3_client = require_s3_client(s3_path)
|
|
response = s3_client.get_object(Bucket=bucket, Key=key)
|
|
return response['Body'].read()
|
|
except ClientError as e:
|
|
logging.error(f"Error reading S3 file {s3_path}: {e}")
|
|
raise
|
|
|
|
|
|
# Cache for tar file indexes (filename -> {offset, size}) to avoid repeated scans
|
|
_tar_index_cache = {}
|
|
|
|
# Cache for DALI index files
|
|
_dali_index_cache = {}
|
|
|
|
|
|
def parse_dali_index(index_content):
|
|
"""Parse a DALI index file content into a lookup dictionary.
|
|
|
|
DALI index format:
|
|
v1.2 64 # header line
|
|
<type> <offset> <size> <filename> # one line per file
|
|
|
|
Args:
|
|
index_content: String content of the DALI index file
|
|
|
|
Returns:
|
|
dict: Mapping of filename -> {'offset': int, 'size': int}
|
|
"""
|
|
index = {}
|
|
lines = index_content.strip().split('\n')
|
|
|
|
for line in lines[1:]: # Skip header line
|
|
parts = line.split()
|
|
if len(parts) >= 4:
|
|
offset = int(parts[1])
|
|
size = int(parts[2])
|
|
filename = parts[3]
|
|
|
|
index[filename] = {'offset': offset, 'size': size, 'name': filename}
|
|
# Also index by basename for easier lookup
|
|
basename = os.path.basename(filename)
|
|
if basename and basename != filename:
|
|
index[basename] = {'offset': offset, 'size': size, 'name': filename}
|
|
|
|
return index
|
|
|
|
|
|
def add_tar_index_entry(index, filename, offset, size):
|
|
"""Add a tar member to an index, including a basename alias."""
|
|
file_info = {'offset': offset, 'size': size, 'name': filename}
|
|
index[filename] = file_info
|
|
|
|
basename = os.path.basename(filename)
|
|
if basename and basename != filename:
|
|
index[basename] = file_info
|
|
|
|
|
|
def count_tar_index_files(index):
|
|
"""Count unique tar members in an index that also includes basename aliases."""
|
|
return len({file_info.get('name', name) for name, file_info in index.items()})
|
|
|
|
|
|
def tar_index_stem(tar_path):
|
|
"""Return the DALI index stem for a tar path."""
|
|
tar_filename = os.path.basename(str(tar_path))
|
|
lower_filename = tar_filename.lower()
|
|
for suffix in ('.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar'):
|
|
if lower_filename.endswith(suffix):
|
|
return tar_filename[: -len(suffix)]
|
|
return tar_filename.rsplit('.', 1)[0]
|
|
|
|
|
|
def get_dali_index_path(tar_path, dali_index_base=None):
|
|
"""Construct the DALI index file path for a given tar file.
|
|
|
|
If dali_index_base is not provided, automatically derives it from tar path:
|
|
s3://bucket/tarred/audio_0.tar -> s3://bucket/tarred/dali_index/audio_0.index
|
|
/data/tarred/audio_0.tar -> /data/tarred/dali_index/audio_0.index
|
|
|
|
Args:
|
|
tar_path: Path to the tar file (local or S3).
|
|
dali_index_base: Optional base path for DALI index files (local or S3).
|
|
If None, auto-derives as tar_directory/dali_index/
|
|
|
|
Returns:
|
|
str: Path to the corresponding index file
|
|
"""
|
|
tar_name = tar_index_stem(tar_path)
|
|
|
|
# Auto-derive dali_index_base if not provided
|
|
if dali_index_base is None:
|
|
# Get the directory containing the tar file
|
|
tar_dir = os.path.dirname(str(tar_path))
|
|
dali_index_base = f"{tar_dir}/dali_index" if tar_dir else "dali_index"
|
|
|
|
# Construct index path
|
|
if str(dali_index_base).endswith('/'):
|
|
return f"{dali_index_base}{tar_name}.index"
|
|
else:
|
|
return f"{dali_index_base}/{tar_name}.index"
|
|
|
|
|
|
def load_dali_index(tar_path, dali_index_base=None):
|
|
"""Load and cache a DALI index file from local storage or S3.
|
|
|
|
If dali_index_base is not provided, automatically tries the standard location:
|
|
s3://bucket/tarred/audio_0.tar -> s3://bucket/tarred/dali_index/audio_0.index
|
|
/data/tarred/audio_0.tar -> /data/tarred/dali_index/audio_0.index
|
|
|
|
Args:
|
|
tar_path: Path to the tar file (local or S3).
|
|
dali_index_base: Optional base path for DALI index files.
|
|
|
|
Returns:
|
|
dict: The parsed DALI index, or None if not found
|
|
"""
|
|
global _dali_index_cache
|
|
|
|
cache_key = (str(tar_path), str(dali_index_base) if dali_index_base is not None else None)
|
|
if cache_key in _dali_index_cache:
|
|
return _dali_index_cache[cache_key]
|
|
|
|
index_path = get_dali_index_path(tar_path, dali_index_base)
|
|
logging.info(f"Loading DALI index: {index_path}")
|
|
|
|
try:
|
|
if is_s3_path(index_path):
|
|
index_content = read_s3_file(index_path)
|
|
else:
|
|
with open(expanduser(index_path), 'r', encoding='utf-8') as index_file:
|
|
index_content = index_file.read()
|
|
index = parse_dali_index(index_content)
|
|
logging.info(f"DALI index loaded: {count_tar_index_files(index)} files, {len(index_content)/1024:.1f} KB")
|
|
_dali_index_cache[cache_key] = index
|
|
return index
|
|
except (FileNotFoundError, OSError) as e:
|
|
logging.warning(f"DALI index not found at {index_path}: {e}")
|
|
return None
|
|
except Exception as e:
|
|
if is_s3_path(index_path) and isinstance(e, ClientError):
|
|
logging.warning(f"DALI index not found at {index_path}: {e}")
|
|
return None
|
|
raise
|
|
|
|
|
|
def read_s3_range(s3_path, start_byte, end_byte):
|
|
"""Read a specific byte range from an S3 file.
|
|
|
|
Args:
|
|
s3_path: S3 URL to the file
|
|
start_byte: Starting byte offset (inclusive)
|
|
end_byte: Ending byte offset (inclusive)
|
|
|
|
Returns:
|
|
bytes: The requested byte range
|
|
"""
|
|
try:
|
|
bucket, key = parse_s3_path(s3_path)
|
|
s3_client = require_s3_client(s3_path)
|
|
range_size = end_byte - start_byte + 1
|
|
logging.info(f"S3 Range request: bytes={start_byte}-{end_byte} (size: {range_size} bytes) from {s3_path}")
|
|
response = s3_client.get_object(Bucket=bucket, Key=key, Range=f'bytes={start_byte}-{end_byte}')
|
|
data = response['Body'].read()
|
|
logging.info(f"S3 Range request completed: received {len(data)} bytes")
|
|
return data
|
|
except ClientError as e:
|
|
logging.error(f"Error reading S3 file range {s3_path} [{start_byte}-{end_byte}]: {e}")
|
|
raise
|
|
|
|
|
|
def build_tar_index_from_s3(tar_s3_path, chunk_size=512 * 1024):
|
|
"""Build an index of files in a tar archive on S3 by reading only headers.
|
|
|
|
Uses S3 Range requests to read tar headers in large chunks,
|
|
minimizing the number of HTTP requests while avoiding downloading the entire tar.
|
|
|
|
Args:
|
|
tar_s3_path: S3 URL to the tar file
|
|
chunk_size: Size of chunks to read at a time (default 512KB)
|
|
|
|
Returns:
|
|
dict: Mapping of filename -> {'offset': data_start_byte, 'size': file_size}
|
|
"""
|
|
logging.info(f"Building tar index by scanning headers: {tar_s3_path}")
|
|
index = {}
|
|
offset = 0
|
|
total_bytes_downloaded = 0
|
|
total_content_size = 0
|
|
num_requests = 0
|
|
|
|
# Buffer for reading tar data
|
|
buffer = b''
|
|
buffer_start_offset = 0
|
|
|
|
while True:
|
|
# Calculate position within our buffer
|
|
buffer_offset = offset - buffer_start_offset
|
|
|
|
# If we need more data, fetch a new chunk
|
|
if buffer_offset >= len(buffer) or len(buffer) - buffer_offset < 512:
|
|
try:
|
|
# Read a large chunk starting from current offset
|
|
chunk = read_s3_range(tar_s3_path, offset, offset + chunk_size - 1)
|
|
num_requests += 1
|
|
total_bytes_downloaded += len(chunk)
|
|
buffer = chunk
|
|
buffer_start_offset = offset
|
|
buffer_offset = 0
|
|
except ClientError as e:
|
|
if 'InvalidRange' in str(e):
|
|
break
|
|
raise
|
|
|
|
if len(buffer) - buffer_offset < 512:
|
|
break
|
|
|
|
# Get the 512-byte header from buffer
|
|
header = buffer[buffer_offset : buffer_offset + 512]
|
|
|
|
# Check for end-of-archive marker (two consecutive zero blocks)
|
|
if header[:100] == b'\x00' * 100:
|
|
break
|
|
|
|
# Parse tar header fields
|
|
# Name: bytes 0-99, Size: bytes 124-135 (octal string)
|
|
name = header[:100].rstrip(b'\x00').decode('utf-8', errors='replace')
|
|
size_str = header[124:136].rstrip(b'\x00 ')
|
|
if not size_str:
|
|
break
|
|
size = int(size_str, 8)
|
|
total_content_size += size
|
|
|
|
# Store the data offset (right after the header) and size
|
|
if name: # Skip empty names
|
|
add_tar_index_entry(index, name, offset + 512, size)
|
|
|
|
# Move to next header: current header (512) + data (size rounded up to 512)
|
|
offset += 512 + ((size + 511) // 512) * 512
|
|
|
|
logging.info(
|
|
f"Tar index built: {count_tar_index_files(index)} files, {num_requests} requests, {total_bytes_downloaded/1024:.1f} KB downloaded"
|
|
)
|
|
return index
|
|
|
|
|
|
def build_tar_index_from_local(tar_path):
|
|
"""Build an index of files in a local tar archive."""
|
|
logging.info(f"Building local tar index by scanning headers: {tar_path}")
|
|
index = {}
|
|
tar_path = expanduser(str(tar_path))
|
|
|
|
with tarfile.open(tar_path, 'r:*') as tar:
|
|
for member in tar:
|
|
if member.isfile():
|
|
add_tar_index_entry(index, member.name, member.offset_data, member.size)
|
|
|
|
logging.info(f"Local tar index built: {count_tar_index_files(index)} files")
|
|
return index
|
|
|
|
|
|
def read_local_range(path, start_byte, end_byte):
|
|
"""Read a specific byte range from a local file."""
|
|
range_size = end_byte - start_byte + 1
|
|
logging.info(f"Local range read: bytes={start_byte}-{end_byte} (size: {range_size} bytes) from {path}")
|
|
with open(expanduser(str(path)), 'rb') as tar_file:
|
|
tar_file.seek(start_byte)
|
|
return tar_file.read(range_size)
|
|
|
|
|
|
def is_compressed_tar_path(tar_path):
|
|
"""Return True when local tar extraction must go through tarfile streams."""
|
|
tar_path = str(tar_path).lower()
|
|
return tar_path.endswith(('.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz'))
|
|
|
|
|
|
def get_tar_index(tar_path, dali_index_base=None):
|
|
"""Get or build the tar index for a given tar file.
|
|
|
|
Automatically tries to load DALI index from standard location first (fast):
|
|
s3://bucket/tarred/audio_0.tar -> s3://bucket/tarred/dali_index/audio_0.index
|
|
/data/tarred/audio_0.tar -> /data/tarred/dali_index/audio_0.index
|
|
|
|
Falls back to scanning tar headers if DALI index is not available.
|
|
|
|
Args:
|
|
tar_path: Path to the tar file (local or S3).
|
|
dali_index_base: Optional base path for DALI index files.
|
|
|
|
Returns:
|
|
dict: The tar index mapping filenames to offsets and sizes
|
|
"""
|
|
global _tar_index_cache
|
|
|
|
cache_key = (str(tar_path), str(dali_index_base) if dali_index_base is not None else None)
|
|
if cache_key in _tar_index_cache:
|
|
return _tar_index_cache[cache_key]
|
|
|
|
# Always try DALI index first (fast - single small file download)
|
|
# It will auto-derive the path if dali_index_base is None
|
|
dali_index = load_dali_index(tar_path, dali_index_base)
|
|
if dali_index:
|
|
_tar_index_cache[cache_key] = dali_index
|
|
return dali_index
|
|
|
|
logging.warning("DALI index not found, falling back to tar header scanning")
|
|
if is_s3_path(tar_path):
|
|
# Fall back to scanning tar headers with S3 range requests (slow for large tars)
|
|
_tar_index_cache[cache_key] = build_tar_index_from_s3(tar_path)
|
|
else:
|
|
_tar_index_cache[cache_key] = build_tar_index_from_local(tar_path)
|
|
return _tar_index_cache[cache_key]
|
|
|
|
|
|
def find_tar_index_entry(index, audio_filename, tar_path):
|
|
"""Find an audio file in a tar index by exact path, basename, or suffix."""
|
|
# Try to find the file in the index
|
|
target_key = None
|
|
|
|
# Try exact match first
|
|
if audio_filename in index:
|
|
target_key = audio_filename
|
|
else:
|
|
# Try basename match
|
|
basename = os.path.basename(audio_filename)
|
|
if basename in index:
|
|
target_key = basename
|
|
else:
|
|
# Try to find by suffix
|
|
for name in index:
|
|
if name.endswith(audio_filename) or name.endswith('/' + audio_filename):
|
|
target_key = name
|
|
break
|
|
|
|
if target_key is None:
|
|
available = list(index.keys())[:10]
|
|
raise FileNotFoundError(
|
|
f"Audio file '{audio_filename}' not found in tar archive {tar_path}. " f"Available files: {available}..."
|
|
)
|
|
|
|
return target_key, index[target_key]
|
|
|
|
|
|
def get_audio_from_s3_tar(tar_s3_path, audio_filename, dali_index_base=None):
|
|
"""Extract an audio file from a tar archive stored on S3 using Range requests.
|
|
|
|
This function only downloads the specific audio file bytes, not the entire tar.
|
|
If dali_index_base is provided, uses DALI index for instant offset lookup.
|
|
Otherwise falls back to scanning tar headers.
|
|
|
|
Args:
|
|
tar_s3_path: S3 URL to the tar file (e.g., s3://bucket/audio_0.tar)
|
|
audio_filename: Name of the audio file within the tar (e.g., audio1.wav)
|
|
dali_index_base: Optional base path for DALI index files
|
|
|
|
Returns:
|
|
bytes: Audio file contents
|
|
"""
|
|
index = get_tar_index(tar_s3_path, dali_index_base)
|
|
target_key, file_info = find_tar_index_entry(index, audio_filename, tar_s3_path)
|
|
offset = file_info['offset']
|
|
size = file_info['size']
|
|
|
|
# Fetch ONLY the audio file bytes using Range request
|
|
logging.info(f"Fetching audio from tar: {target_key} (offset={offset}, size={size/1024:.1f} KB)")
|
|
audio_bytes = read_s3_range(tar_s3_path, offset, offset + size - 1)
|
|
logging.debug(f"Audio fetched: {len(audio_bytes)} bytes")
|
|
return audio_bytes
|
|
|
|
|
|
def get_audio_from_local_tar(tar_path, audio_filename, dali_index_base=None):
|
|
"""Extract an audio file from a local tar archive."""
|
|
index = get_tar_index(tar_path, dali_index_base)
|
|
target_key, file_info = find_tar_index_entry(index, audio_filename, tar_path)
|
|
offset = file_info['offset']
|
|
size = file_info['size']
|
|
|
|
logging.info(f"Fetching audio from local tar: {target_key} (offset={offset}, size={size/1024:.1f} KB)")
|
|
if not is_compressed_tar_path(tar_path):
|
|
return read_local_range(tar_path, offset, offset + size - 1)
|
|
|
|
member_name = file_info.get('name', target_key)
|
|
with tarfile.open(expanduser(str(tar_path)), 'r:*') as tar:
|
|
try:
|
|
member = tar.getmember(member_name)
|
|
except KeyError:
|
|
member = tar.getmember(target_key)
|
|
extracted = tar.extractfile(member)
|
|
if extracted is None:
|
|
raise FileNotFoundError(f"Audio file '{audio_filename}' could not be extracted from {tar_path}")
|
|
return extracted.read()
|
|
|
|
|
|
def load_audio_from_s3(audio_filepath, tar_path=None, dali_index_base=None):
|
|
"""Load audio data from S3, supporting both direct files and tarred audio.
|
|
|
|
Args:
|
|
audio_filepath: The audio file path (e.g., "audio1.wav" for tarred, or full S3 URL)
|
|
tar_path: Resolved S3 path to the tar file (e.g., "s3://ASR/tarred/audio_0.tar").
|
|
If provided, audio_filepath is treated as a file within this tar.
|
|
dali_index_base: Optional base S3 path for DALI index files (for fast offset lookup)
|
|
|
|
Returns:
|
|
io.BytesIO: BytesIO object for librosa to read
|
|
"""
|
|
if tar_path and is_s3_path(tar_path):
|
|
logging.debug(f"Loading audio from S3 tar: {tar_path}")
|
|
audio_bytes = get_audio_from_s3_tar(tar_path, audio_filepath, dali_index_base)
|
|
return io.BytesIO(audio_bytes)
|
|
elif is_s3_path(audio_filepath):
|
|
audio_bytes = read_s3_file_bytes(audio_filepath)
|
|
return io.BytesIO(audio_bytes)
|
|
else:
|
|
raise ValueError(f"Cannot load audio: {audio_filepath} (tar_path={tar_path})")
|
|
|
|
|
|
def open_manifest_file(manifest_path):
|
|
"""Open a manifest file, supporting both local and S3 paths.
|
|
|
|
Args:
|
|
manifest_path: Path to the manifest file (local or S3 URL)
|
|
|
|
Yields:
|
|
str: Lines from the manifest file
|
|
"""
|
|
if is_s3_path(manifest_path):
|
|
content = read_s3_file(manifest_path)
|
|
for line in content.splitlines():
|
|
yield line
|
|
else:
|
|
with open(manifest_path, 'r', encoding='utf8') as f:
|
|
for line in f:
|
|
yield line.rstrip('\n')
|
|
|
|
|
|
# operators for filtering items
|
|
filter_operators = {
|
|
'>=': 'ge',
|
|
'<=': 'le',
|
|
'<': 'lt',
|
|
'>': 'gt',
|
|
'!=': 'ne',
|
|
'=': 'eq',
|
|
'contains ': 'contains',
|
|
}
|
|
|
|
|
|
# parse table filter queries
|
|
def split_filter_part(filter_part):
|
|
for op in filter_operators:
|
|
if op in filter_part:
|
|
name_part, value_part = filter_part.split(op, 1)
|
|
name = name_part[name_part.find('{') + 1 : name_part.rfind('}')]
|
|
value_part = value_part.strip()
|
|
v0 = value_part[0]
|
|
if v0 == value_part[-1] and v0 in ("'", '"', '`'):
|
|
value = value_part[1:-1].replace('\\' + v0, v0)
|
|
else:
|
|
try:
|
|
value = float(value_part)
|
|
except ValueError:
|
|
value = value_part
|
|
return name, filter_operators[op], value
|
|
return [None] * 3
|
|
|
|
|
|
# standard command-line arguments parser
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description='Speech Data Explorer')
|
|
parser.add_argument(
|
|
'manifest',
|
|
nargs='+',
|
|
help='Path(s) to JSON manifest file(s). Accepts one or two manifests. '
|
|
'When two manifests are provided, -nc (--names_compared) is required and '
|
|
'each manifest must contain a plain "pred_text" field. '
|
|
'Supports S3 paths (s3://bucket/path) and '
|
|
'sharded patterns using _OP_start..end_CL_ syntax '
|
|
'(e.g., s3://ASR/manifests/manifest__OP_0..255_CL_.json)',
|
|
)
|
|
parser.add_argument('--vocab', help='optional vocabulary to highlight OOV words')
|
|
parser.add_argument('--port', default='8050', help='serving port for establishing connection')
|
|
parser.add_argument(
|
|
'--estimate-audio-metrics',
|
|
'-a',
|
|
action='store_true',
|
|
help='estimate frequency bandwidth and signal level of audio recordings',
|
|
)
|
|
parser.add_argument(
|
|
'--audio-base-path',
|
|
default=None,
|
|
type=str,
|
|
help='A base path for the relative paths in manifest. It defaults to manifest path.',
|
|
)
|
|
parser.add_argument(
|
|
'--tar-base-path',
|
|
default=None,
|
|
type=str,
|
|
help='Path to tarred audio files, local or S3 (e.g., /data/tarred/audio_0.tar or s3://ASR/tarred/audio_0.tar). '
|
|
'Supports sharded patterns using _OP_start..end_CL_ syntax '
|
|
'(e.g., /data/tarred/audio__OP_0..255_CL_.tar). '
|
|
'When using sharded manifests, the tar shard index is automatically matched '
|
|
'to the manifest shard index. '
|
|
'When specified, audio_filepath values in the manifest are treated as '
|
|
'filenames within the corresponding tar archive.',
|
|
)
|
|
parser.add_argument(
|
|
'--dali-index-base',
|
|
default=None,
|
|
type=str,
|
|
help='Path to DALI index files directory, local or S3 (e.g., /data/tarred/dali_index/ or s3://bucket/tarred/dali_index/). '
|
|
'When provided, uses DALI index files for instant file offset lookup instead of '
|
|
'scanning tar headers. This dramatically speeds up audio loading for large tar files. '
|
|
'If not specified, automatically looks for index at <tar_dir>/dali_index/<tar_name>.index. '
|
|
'Index files should be named audio_0.index, audio_1.index, etc. matching the tar files.',
|
|
)
|
|
parser.add_argument('--debug', '-d', action='store_true', help='enable debug mode')
|
|
|
|
parser.add_argument(
|
|
'--names_compared',
|
|
'-nc',
|
|
nargs=2,
|
|
type=str,
|
|
help='Names of the two fields that will be compared, example: pred_text_contextnet pred_text_conformer. "pred_text_" prefix IS IMPORTANT!',
|
|
)
|
|
parser.add_argument(
|
|
'--show_statistics',
|
|
'-shst',
|
|
type=str,
|
|
help='Field name for which you want to see statistics (optional). Example: pred_text_contextnet.',
|
|
)
|
|
parser.add_argument(
|
|
'--force',
|
|
'-f',
|
|
action='store_true',
|
|
help=(
|
|
'Tolerate manifest entries missing required fields. Missing "text", '
|
|
'"duration", "audio_filepath" default to "", 0, "" respectively; rows '
|
|
'with non-string "text" are also coerced. WER/CER for rows with empty '
|
|
'reference text are meaningless but the dashboard will still load.'
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
'--s3cfg',
|
|
'-s3c',
|
|
type=str,
|
|
default='',
|
|
help=(
|
|
'Path to the s3 credentials file and section. Example: ~/.s3cfg[default]. '
|
|
'Or the literal string "AIS" to read credentials from AIS_ENDPOINT and '
|
|
'AIS_AUTHN_TOKEN environment variables. Set to "" to disable S3 support. '
|
|
'Default is "".'
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Validate manifest count
|
|
if len(args.manifest) > 2:
|
|
parser.error('At most two manifest files can be provided.')
|
|
if len(args.manifest) == 2 and args.names_compared is None:
|
|
parser.error('When two manifest files are provided, -nc/--names_compared is required.')
|
|
|
|
s3_cli_paths = [
|
|
path
|
|
for path in [*args.manifest, args.audio_base_path, args.tar_base_path, args.dali_index_base]
|
|
if is_s3_path(path)
|
|
]
|
|
if s3_cli_paths and not args.s3cfg:
|
|
parser.error(
|
|
f"S3 paths require --s3cfg, but it was not provided. First S3 path: {s3_cli_paths[0]}. "
|
|
"Example: --s3cfg ~/.s3cfg[default]. "
|
|
"For AIS, use --s3cfg AIS with AIS_ENDPOINT and AIS_AUTHN_TOKEN set."
|
|
)
|
|
|
|
dual_manifest_mode = len(args.manifest) == 2
|
|
|
|
# assume audio_filepath is relative to the directory where the manifest is stored
|
|
# For S3 paths, we cannot use os.path.dirname, so leave it as None
|
|
primary_manifest = args.manifest[0]
|
|
if args.audio_base_path is None:
|
|
if is_s3_path(primary_manifest):
|
|
# For S3 manifests, audio_base_path should be explicitly provided
|
|
# or tar_base_path should be used
|
|
args.audio_base_path = None
|
|
else:
|
|
args.audio_base_path = os.path.dirname(primary_manifest)
|
|
|
|
# automaticly going in comparison mode, if there is names_compared argument
|
|
if args.names_compared is not None:
|
|
comparison_mode = True
|
|
logging.info("comparison mod set to true")
|
|
else:
|
|
comparison_mode = False
|
|
|
|
logging.debug(f"Parsed args: {args}, comparison_mode: {comparison_mode}, dual_manifest_mode: {dual_manifest_mode}")
|
|
return args, comparison_mode, dual_manifest_mode
|
|
|
|
|
|
# estimate frequency bandwidth of signal
|
|
def eval_bandwidth(signal, sr, threshold=-50):
|
|
time_stride = 0.01
|
|
hop_length = int(sr * time_stride)
|
|
n_fft = 512
|
|
spectrogram = np.mean(
|
|
np.abs(librosa.stft(y=signal, n_fft=n_fft, hop_length=hop_length, window='blackmanharris')) ** 2, axis=1
|
|
)
|
|
power_spectrum = librosa.power_to_db(S=spectrogram, ref=np.max, top_db=100)
|
|
freqband = 0
|
|
for idx in range(len(power_spectrum) - 1, -1, -1):
|
|
if power_spectrum[idx] > threshold:
|
|
freqband = idx / n_fft * sr
|
|
break
|
|
return freqband
|
|
|
|
|
|
# load data from JSON manifest file
|
|
def load_data(
|
|
data_filename,
|
|
estimate_audio=False,
|
|
vocab=None,
|
|
audio_base_path=None,
|
|
comparison_mode=False,
|
|
names=None,
|
|
tar_base_path=None,
|
|
dali_index_base=None,
|
|
force=False,
|
|
):
|
|
if comparison_mode:
|
|
if names is None:
|
|
logging.error(f'Please, specify names of compared models')
|
|
name_1, name_2 = names
|
|
|
|
if not comparison_mode:
|
|
if vocab is not None:
|
|
# load external vocab
|
|
vocabulary_ext = {}
|
|
with open(vocab, 'r') as f:
|
|
for line in f:
|
|
if '\t' in line:
|
|
# parse word from TSV file
|
|
word = line.split('\t')[0]
|
|
else:
|
|
# assume each line contains just a single word
|
|
word = line.strip()
|
|
vocabulary_ext[word] = 1
|
|
|
|
data = []
|
|
wer_count = 0
|
|
cer_count = 0
|
|
wmr_count = 0
|
|
wer = 0
|
|
cer = 0
|
|
wmr = 0
|
|
mwa = 0
|
|
num_hours = 0
|
|
match_vocab_1 = defaultdict(lambda: 0)
|
|
match_vocab_2 = defaultdict(lambda: 0)
|
|
|
|
def append_data(
|
|
data_filename,
|
|
estimate_audio,
|
|
field_name='pred_text',
|
|
):
|
|
data = []
|
|
wer_dist = 0.0
|
|
wer_count = 0
|
|
cer_dist = 0.0
|
|
cer_count = 0
|
|
wmr_count = 0
|
|
wer = 0
|
|
cer = 0
|
|
wmr = 0
|
|
mwa = 0
|
|
num_hours = 0
|
|
vocabulary = defaultdict(lambda: 0)
|
|
alphabet = set()
|
|
match_vocab = defaultdict(lambda: 0)
|
|
|
|
sm = difflib.SequenceMatcher()
|
|
metrics_available = False
|
|
|
|
# Expand sharded manifest paths if pattern is present
|
|
manifest_paths = expand_sharded_path(data_filename)
|
|
|
|
# Pre-expand tar paths in the same order so Nth manifest -> Nth tar
|
|
if tar_base_path and is_sharded_path(tar_base_path):
|
|
tar_paths = expand_sharded_path(tar_base_path)
|
|
if len(tar_paths) != len(manifest_paths):
|
|
logging.error(
|
|
f"Manifest count ({len(manifest_paths)}) != tar count ({len(tar_paths)}). "
|
|
f"The _OP_/_CL_ ranges in --manifest and --tar-base-path must match."
|
|
)
|
|
logging.error(f" Manifest pattern: {data_filename}")
|
|
logging.error(f" Tar pattern: {tar_base_path}")
|
|
logging.error(f" First manifest: {manifest_paths[0]}")
|
|
logging.error(f" First tar: {tar_paths[0]}")
|
|
logging.error(f" Last manifest: {manifest_paths[-1]}")
|
|
logging.error(f" Last tar: {tar_paths[-1]}")
|
|
raise ValueError(
|
|
f"Manifest/tar count mismatch: {len(manifest_paths)} manifests vs {len(tar_paths)} tars. "
|
|
f"Fix the _OP_/_CL_ ranges so they match."
|
|
)
|
|
# Log first few mappings so user can verify correctness
|
|
logging.info(f"Manifest-to-tar mapping ({len(manifest_paths)} pairs):")
|
|
for i in range(min(3, len(manifest_paths))):
|
|
logging.info(f" [{i}] {manifest_paths[i]} -> {tar_paths[i]}")
|
|
if len(manifest_paths) > 3:
|
|
logging.info(f" ... ({len(manifest_paths) - 3} more)")
|
|
elif tar_base_path:
|
|
# Non-sharded tar: same tar for all manifests
|
|
tar_paths = [tar_base_path] * len(manifest_paths)
|
|
else:
|
|
tar_paths = [None] * len(manifest_paths)
|
|
|
|
logging.info(f"Loading {len(manifest_paths)} manifest file(s)")
|
|
|
|
for manifest_idx, manifest_path in enumerate(manifest_paths):
|
|
# Resolved tar path for this manifest shard
|
|
resolved_tar_path = tar_paths[manifest_idx] if manifest_idx < len(tar_paths) else None
|
|
|
|
# Support both local files and S3 paths
|
|
manifest_lines = list(open_manifest_file(manifest_path))
|
|
desc = f"Shard {manifest_idx}" if len(manifest_paths) > 1 else manifest_path
|
|
for line in tqdm.tqdm(manifest_lines, desc=desc):
|
|
item = json.loads(line)
|
|
if force:
|
|
item.setdefault(TEXT_KEY, '')
|
|
item.setdefault('duration', 0)
|
|
item.setdefault('audio_filepath', '')
|
|
if TEXT_KEY not in item or not isinstance(item[TEXT_KEY], str):
|
|
item[TEXT_KEY] = ''
|
|
num_chars = len(item[TEXT_KEY])
|
|
orig = item[TEXT_KEY].split()
|
|
num_words = len(orig)
|
|
for word in orig:
|
|
vocabulary[word] += 1
|
|
for char in item[TEXT_KEY]:
|
|
alphabet.add(char)
|
|
num_hours += item['duration']
|
|
|
|
if field_name in item and item[TEXT_KEY]:
|
|
metrics_available = True
|
|
pred = item[field_name].split()
|
|
measures = edit_distance(item[TEXT_KEY].split(), item[field_name].split())
|
|
word_dist = measures['total']
|
|
char_dist = edit_distance(list(item[TEXT_KEY]), list(item[field_name]))['total']
|
|
wer_dist += word_dist
|
|
cer_dist += char_dist
|
|
wer_count += num_words
|
|
cer_count += num_chars
|
|
|
|
sm.set_seqs(orig, pred)
|
|
for m in sm.get_matching_blocks():
|
|
for word_idx in range(m[0], m[0] + m[2]):
|
|
match_vocab[orig[word_idx]] += 1
|
|
wmr_count += num_words - measures['sub'] - measures['del']
|
|
elif field_name in item:
|
|
pass
|
|
else:
|
|
if comparison_mode:
|
|
if field_name != 'pred_text':
|
|
if field_name == name_1:
|
|
logging.error(f"The .json file has no field with name: {name_1}")
|
|
exit()
|
|
if field_name == name_2:
|
|
logging.error(f"The .json file has no field with name: {name_2}")
|
|
exit()
|
|
entry = {
|
|
'audio_filepath': item['audio_filepath'],
|
|
'duration': round(item['duration'], 2),
|
|
'num_words': num_words,
|
|
'num_chars': num_chars,
|
|
'word_rate': round(num_words / item['duration'], 2) if item['duration'] > 0 else 0,
|
|
'char_rate': round(num_chars / item['duration'], 2) if item['duration'] > 0 else 0,
|
|
'text': item[TEXT_KEY],
|
|
}
|
|
# Store resolved tar path for this entry (needed for audio playback)
|
|
if resolved_tar_path is not None:
|
|
entry['_tar_path'] = resolved_tar_path
|
|
data.append(entry)
|
|
if metrics_available:
|
|
data[-1][field_name] = item[field_name]
|
|
if num_words == 0:
|
|
num_words = 1e-9
|
|
if num_chars == 0:
|
|
num_chars = 1e-9
|
|
data[-1]['WER'] = round(word_dist / num_words * 100.0, 2)
|
|
data[-1]['CER'] = round(char_dist / num_chars * 100.0, 2)
|
|
data[-1]['WMR'] = round(measures['hits'] / num_words * 100.0, 2)
|
|
data[-1]['I'] = measures['insertions']
|
|
data[-1]['D'] = measures['deletions']
|
|
data[-1]['D-I'] = measures['deletions'] - measures['insertions']
|
|
if estimate_audio:
|
|
try:
|
|
signal, sr = load_audio_data(
|
|
item['audio_filepath'], audio_base_path, resolved_tar_path, dali_index_base
|
|
)
|
|
bw = eval_bandwidth(signal, sr)
|
|
item['freq_bandwidth'] = int(bw)
|
|
item['level_db'] = 20 * np.log10(np.max(np.abs(signal)))
|
|
except (FileNotFoundError, OSError, ValueError) as e:
|
|
if force:
|
|
logging.warning(f"skip audio metrics for {item.get('audio_filepath','?')}: {e}")
|
|
else:
|
|
raise
|
|
for k in item:
|
|
if k not in data[-1] and not isinstance(item[k], (list, dict)):
|
|
data[-1][k] = item[k]
|
|
|
|
vocabulary_data = [{'word': word, 'count': vocabulary[word]} for word in vocabulary]
|
|
return (
|
|
vocabulary_data,
|
|
metrics_available,
|
|
data,
|
|
wer_dist,
|
|
wer_count,
|
|
cer_dist,
|
|
cer_count,
|
|
wmr_count,
|
|
wer,
|
|
cer,
|
|
wmr,
|
|
mwa,
|
|
num_hours,
|
|
vocabulary,
|
|
alphabet,
|
|
match_vocab,
|
|
)
|
|
|
|
(
|
|
vocabulary_data,
|
|
metrics_available,
|
|
data,
|
|
wer_dist,
|
|
wer_count,
|
|
cer_dist,
|
|
cer_count,
|
|
wmr_count,
|
|
wer,
|
|
cer,
|
|
wmr,
|
|
mwa,
|
|
num_hours,
|
|
vocabulary,
|
|
alphabet,
|
|
match_vocab,
|
|
) = append_data(data_filename, estimate_audio, field_name=fld_nm)
|
|
if comparison_mode:
|
|
(
|
|
vocabulary_data_1,
|
|
metrics_available_1,
|
|
data_1,
|
|
wer_dist_1,
|
|
wer_count_1,
|
|
cer_dist_1,
|
|
cer_count_1,
|
|
wmr_count_1,
|
|
wer_1,
|
|
cer_1,
|
|
wmr_1,
|
|
mwa_1,
|
|
num_hours_1,
|
|
vocabulary_1,
|
|
alphabet_1,
|
|
match_vocab_1,
|
|
) = append_data(data_filename, estimate_audio, field_name=name_1)
|
|
(
|
|
vocabulary_data_2,
|
|
metrics_available_2,
|
|
data_2,
|
|
wer_dist_2,
|
|
wer_count_2,
|
|
cer_dist_2,
|
|
cer_count_2,
|
|
wmr_count_2,
|
|
wer_2,
|
|
cer_2,
|
|
wmr_2,
|
|
mwa_2,
|
|
num_hours_2,
|
|
vocabulary_2,
|
|
alphabet_2,
|
|
match_vocab_2,
|
|
) = append_data(data_filename, estimate_audio, field_name=name_2)
|
|
|
|
if not comparison_mode:
|
|
if vocab is not None:
|
|
for item in vocabulary_data:
|
|
item['OOV'] = item['word'] not in vocabulary_ext
|
|
|
|
if metrics_available or comparison_mode:
|
|
if metrics_available:
|
|
wer = wer_dist / wer_count * 100.0
|
|
cer = cer_dist / cer_count * 100.0
|
|
wmr = wmr_count / wer_count * 100.0
|
|
if comparison_mode:
|
|
if metrics_available_1 and metrics_available_2:
|
|
wer_1 = wer_dist_1 / wer_count_1 * 100.0
|
|
cer_1 = cer_dist_1 / cer_count_1 * 100.0
|
|
wmr_1 = wmr_count_1 / wer_count_1 * 100.0
|
|
|
|
wer = wer_dist_2 / wer_count_2 * 100.0
|
|
cer = cer_dist_2 / cer_count_2 * 100.0
|
|
wmr = wmr_count_2 / wer_count_2 * 100.0
|
|
|
|
acc_sum_1 = 0
|
|
acc_sum_2 = 0
|
|
|
|
for item in vocabulary_data_1:
|
|
w = item['word']
|
|
word_accuracy_1 = match_vocab_1[w] / vocabulary_1[w] * 100.0
|
|
acc_sum_1 += word_accuracy_1
|
|
item['accuracy_1'] = round(word_accuracy_1, 1)
|
|
mwa_1 = acc_sum_1 / len(vocabulary_data_1) if vocabulary_data_1 else 0
|
|
|
|
for item in vocabulary_data_2:
|
|
w = item['word']
|
|
word_accuracy_2 = match_vocab_2[w] / vocabulary_2[w] * 100.0
|
|
acc_sum_2 += word_accuracy_2
|
|
item['accuracy_2'] = round(word_accuracy_2, 1)
|
|
mwa_2 = acc_sum_2 / len(vocabulary_data_2) if vocabulary_data_2 else 0
|
|
|
|
acc_sum = 0
|
|
for item in vocabulary_data:
|
|
w = item['word']
|
|
word_accuracy = match_vocab[w] / vocabulary[w] * 100.0
|
|
acc_sum += word_accuracy
|
|
item['accuracy'] = round(word_accuracy, 1)
|
|
mwa = acc_sum / len(vocabulary_data) if vocabulary_data else 0
|
|
|
|
num_hours /= 3600.0
|
|
|
|
if comparison_mode:
|
|
return (
|
|
data,
|
|
wer,
|
|
cer,
|
|
wmr,
|
|
mwa,
|
|
num_hours,
|
|
vocabulary_data,
|
|
alphabet,
|
|
metrics_available,
|
|
data_1,
|
|
wer_1,
|
|
cer_1,
|
|
wmr_1,
|
|
mwa_1,
|
|
num_hours_1,
|
|
vocabulary_data_1,
|
|
alphabet_1,
|
|
metrics_available_1,
|
|
data_2,
|
|
wer_2,
|
|
cer_2,
|
|
wmr_2,
|
|
mwa_2,
|
|
num_hours_2,
|
|
vocabulary_data_2,
|
|
alphabet_2,
|
|
metrics_available_2,
|
|
)
|
|
|
|
return data, wer, cer, wmr, mwa, num_hours, vocabulary_data, alphabet, metrics_available
|
|
|
|
|
|
# plot histogram of specified field in data list
|
|
def plot_histogram(data, key, label):
|
|
fig = px.histogram(
|
|
data_frame=[item[key] for item in data if key in item],
|
|
nbins=50,
|
|
log_y=True,
|
|
labels={'value': label},
|
|
opacity=0.5,
|
|
color_discrete_sequence=['green'],
|
|
height=200,
|
|
)
|
|
fig.update_layout(showlegend=False, margin=dict(l=0, r=0, t=0, b=0, pad=0))
|
|
return fig
|
|
|
|
|
|
def plot_word_accuracy(vocabulary_data):
|
|
labels = ['Unrecognized', 'Sometimes recognized', 'Always recognized']
|
|
counts = [0, 0, 0]
|
|
for word in vocabulary_data:
|
|
if word['accuracy'] == 0:
|
|
counts[0] += 1
|
|
elif word['accuracy'] < 100:
|
|
counts[1] += 1
|
|
else:
|
|
counts[2] += 1
|
|
colors = ['red', 'orange', 'green']
|
|
|
|
fig = go.Figure(
|
|
data=[
|
|
go.Bar(
|
|
x=labels,
|
|
y=counts,
|
|
marker_color=colors,
|
|
text=['{:.2%}'.format(count / sum(counts)) for count in counts],
|
|
textposition='auto',
|
|
)
|
|
]
|
|
)
|
|
fig.update_layout(
|
|
showlegend=False, margin=dict(l=0, r=0, t=0, b=0, pad=0), height=200, yaxis={'title_text': '#words'}
|
|
)
|
|
|
|
return fig
|
|
|
|
|
|
def absolute_audio_filepath(audio_filepath, audio_base_path, tar_base_path=None):
|
|
"""Return absolute path to an audio file.
|
|
|
|
Check if a file exists at audio_filepath.
|
|
If not, assume that the path is relative to audio_base_path.
|
|
For S3 paths or tarred audio, returns the original path.
|
|
|
|
Args:
|
|
audio_filepath: Path to audio file (local, S3, or filename within tar)
|
|
audio_base_path: Base path for relative audio files
|
|
tar_base_path: Path to tar file containing audio (optional)
|
|
|
|
Returns:
|
|
str: The resolved audio filepath
|
|
"""
|
|
# If using tarred audio, just return the filename as-is.
|
|
# The actual loading will be handled by load_audio_data
|
|
if tar_base_path:
|
|
return str(audio_filepath)
|
|
|
|
# If audio_filepath is already an S3 path, return as-is
|
|
if is_s3_path(audio_filepath):
|
|
return str(audio_filepath)
|
|
|
|
audio_filepath = Path(audio_filepath)
|
|
|
|
if not audio_filepath.is_file() and not audio_filepath.is_absolute():
|
|
if audio_base_path:
|
|
audio_filepath = Path(audio_base_path) / audio_filepath
|
|
if audio_filepath.is_file():
|
|
filename = str(audio_filepath)
|
|
else:
|
|
filename = expanduser(audio_filepath)
|
|
else:
|
|
filename = expanduser(audio_filepath)
|
|
|
|
return filename
|
|
|
|
|
|
def load_audio_data(audio_filepath, audio_base_path=None, tar_path=None, dali_index_base=None):
|
|
"""Load audio data from local file, S3, or tar archive.
|
|
|
|
Args:
|
|
audio_filepath: Path to audio file (local, S3, or filename within tar)
|
|
audio_base_path: Base path for relative audio files
|
|
tar_path: Resolved path to the tar file (e.g., /data/audio_5.tar or s3://bucket/audio_5.tar).
|
|
Already expanded from any _OP_/_CL_ pattern.
|
|
dali_index_base: Optional base path for DALI index files (for fast offset lookup)
|
|
|
|
Returns:
|
|
tuple: (audio_signal, sample_rate)
|
|
"""
|
|
# Case 1: Tarred audio
|
|
if tar_path:
|
|
if is_s3_path(tar_path):
|
|
audio_buffer = load_audio_from_s3(audio_filepath, tar_path, dali_index_base)
|
|
else:
|
|
audio_buffer = io.BytesIO(get_audio_from_local_tar(tar_path, audio_filepath, dali_index_base))
|
|
return librosa.load(audio_buffer, sr=None)
|
|
|
|
# Case 2: Direct S3 audio file
|
|
if is_s3_path(audio_filepath):
|
|
audio_buffer = load_audio_from_s3(audio_filepath)
|
|
return librosa.load(audio_buffer, sr=None)
|
|
|
|
# Case 3: Local file
|
|
filepath = absolute_audio_filepath(audio_filepath, audio_base_path)
|
|
return librosa.load(path=filepath, sr=None)
|
|
|
|
|
|
def merge_manifests(path1, path2, name1, name2):
|
|
"""Merge two NeMo manifests for dual-manifest NC mode.
|
|
|
|
Rows are aligned by audio_filepath, so manifests may be in different orders
|
|
(e.g. one sorted by duration, the other unsorted). Each manifest must have
|
|
a 'pred_text' field; they are renamed to 'pred_text_{name1}' and
|
|
'pred_text_{name2}' in the output. Entries present in manifest 1 but
|
|
missing in manifest 2 are skipped with an aggregated warning.
|
|
|
|
Returns the path to the merged temporary file (delete=False, so the caller
|
|
does not need to hold a reference).
|
|
"""
|
|
field1 = f'pred_text_{name1}'
|
|
field2 = f'pred_text_{name2}'
|
|
|
|
map2 = {}
|
|
dup_count = 0
|
|
for raw in open_manifest_file(path2):
|
|
if not raw.strip():
|
|
continue
|
|
item = json.loads(raw)
|
|
key = item.get('audio_filepath')
|
|
if key is None:
|
|
logging.error(f"Second manifest has entry without audio_filepath: {item}")
|
|
raise SystemExit(1)
|
|
if key in map2:
|
|
dup_count += 1
|
|
map2[key] = item
|
|
if dup_count:
|
|
logging.warning(f"Second manifest had {dup_count} duplicate audio_filepath entries; last occurrence wins")
|
|
|
|
unmatched = 0
|
|
merged_count = 0
|
|
tmp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8')
|
|
try:
|
|
for raw in open_manifest_file(path1):
|
|
if not raw.strip():
|
|
continue
|
|
item1 = json.loads(raw)
|
|
key = item1.get('audio_filepath')
|
|
if key is None:
|
|
logging.error(f"First manifest has entry without audio_filepath: {item1}")
|
|
raise SystemExit(1)
|
|
item2 = map2.get(key)
|
|
if item2 is None:
|
|
unmatched += 1
|
|
continue
|
|
if 'pred_text' not in item1:
|
|
logging.error(f"First manifest has no 'pred_text' field for {key}")
|
|
raise SystemExit(1)
|
|
if 'pred_text' not in item2:
|
|
logging.error(f"Second manifest has no 'pred_text' field for {key}")
|
|
raise SystemExit(1)
|
|
|
|
merged = dict(item1)
|
|
merged[field1] = merged.pop('pred_text')
|
|
merged[field2] = item2['pred_text']
|
|
tmp_file.write(json.dumps(merged) + '\n')
|
|
merged_count += 1
|
|
finally:
|
|
tmp_file.flush()
|
|
tmp_file.close()
|
|
|
|
if unmatched:
|
|
logging.warning(f"{unmatched} entries from first manifest had no match in second manifest; skipped")
|
|
logging.info(f"Merged {merged_count} entries into temporary file: {tmp_file.name}")
|
|
return tmp_file.name
|
|
|
|
|
|
# parse the CLI arguments
|
|
args, comparison_mode, dual_manifest_mode = parse_args()
|
|
|
|
if args.s3cfg:
|
|
_s3_client = get_s3_client(args.s3cfg)
|
|
|
|
# Handle dual-manifest mode: merge the two manifests into one temp manifest
|
|
# and rewrite names_compared to use the auto-generated pred_text_{name} field names.
|
|
if dual_manifest_mode:
|
|
model_name_1, model_name_2 = args.names_compared
|
|
merged_manifest_path = merge_manifests(args.manifest[0], args.manifest[1], model_name_1, model_name_2)
|
|
data_filename = merged_manifest_path
|
|
args.names_compared = [f'pred_text_{model_name_1}', f'pred_text_{model_name_2}']
|
|
logging.info(f"Dual-manifest mode: using merged manifest at {merged_manifest_path}")
|
|
atexit.register(os.remove, merged_manifest_path)
|
|
else:
|
|
data_filename = args.manifest[0]
|
|
|
|
if args.show_statistics is not None:
|
|
fld_nm = args.show_statistics
|
|
else:
|
|
fld_nm = 'pred_text'
|
|
# parse names of compared models, if any
|
|
if comparison_mode:
|
|
name_1, name_2 = args.names_compared
|
|
logging.debug(f"Comparing models: {name_1} vs {name_2}")
|
|
|
|
|
|
logging.info('Loading data')
|
|
if not comparison_mode:
|
|
data, wer, cer, wmr, mwa, num_hours, vocabulary, alphabet, metrics_available = load_data(
|
|
data_filename,
|
|
args.estimate_audio_metrics,
|
|
args.vocab,
|
|
args.audio_base_path,
|
|
comparison_mode,
|
|
args.names_compared,
|
|
tar_base_path=args.tar_base_path,
|
|
dali_index_base=args.dali_index_base,
|
|
force=args.force,
|
|
)
|
|
else:
|
|
(
|
|
data,
|
|
wer,
|
|
cer,
|
|
wmr,
|
|
mwa,
|
|
num_hours,
|
|
vocabulary,
|
|
alphabet,
|
|
metrics_available,
|
|
data_1,
|
|
wer_1,
|
|
cer_1,
|
|
wmr_1,
|
|
mwa_1,
|
|
num_hours_1,
|
|
vocabulary_1,
|
|
alphabet_1,
|
|
metrics_available_1,
|
|
data_2,
|
|
wer_2,
|
|
cer_2,
|
|
wmr_2,
|
|
mwa_2,
|
|
num_hours_2,
|
|
vocabulary_2,
|
|
alphabet_2,
|
|
metrics_available_2,
|
|
) = load_data(
|
|
data_filename,
|
|
args.estimate_audio_metrics,
|
|
args.vocab,
|
|
args.audio_base_path,
|
|
comparison_mode,
|
|
args.names_compared,
|
|
tar_base_path=args.tar_base_path,
|
|
dali_index_base=args.dali_index_base,
|
|
force=args.force,
|
|
)
|
|
|
|
logging.info('Starting server')
|
|
app = dash.Dash(
|
|
__name__,
|
|
suppress_callback_exceptions=True,
|
|
external_stylesheets=[dbc.themes.BOOTSTRAP],
|
|
title=os.path.basename(args.manifest[0]),
|
|
)
|
|
|
|
figures_labels = {
|
|
'duration': ['Duration', 'Duration, sec'],
|
|
'num_words': ['Number of Words', '#words'],
|
|
'num_chars': ['Number of Characters', '#chars'],
|
|
'word_rate': ['Word Rate', '#words/sec'],
|
|
'char_rate': ['Character Rate', '#chars/sec'],
|
|
'WER': ['Word Error Rate', 'WER, %'],
|
|
'CER': ['Character Error Rate', 'CER, %'],
|
|
'WMR': ['Word Match Rate', 'WMR, %'],
|
|
'I': ['# Insertions (I)', '#words'],
|
|
'D': ['# Deletions (D)', '#words'],
|
|
'D-I': ['# Deletions - # Insertions (D-I)', '#words'],
|
|
'freq_bandwidth': ['Frequency Bandwidth', 'Bandwidth, Hz'],
|
|
'level_db': ['Peak Level', 'Level, dB'],
|
|
}
|
|
figures_hist = {}
|
|
for k in data[0]:
|
|
val = data[0][k]
|
|
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
if k in figures_labels:
|
|
ylabel = figures_labels[k][0]
|
|
xlabel = figures_labels[k][1]
|
|
else:
|
|
title = k.replace('_', ' ')
|
|
title = title[0].upper() + title[1:].lower()
|
|
ylabel = title
|
|
xlabel = title
|
|
figures_hist[k] = [ylabel + ' (per utterance)', plot_histogram(data, k, xlabel)]
|
|
|
|
if metrics_available:
|
|
figure_word_acc = plot_word_accuracy(vocabulary)
|
|
|
|
stats_layout = [
|
|
dbc.Row(dbc.Col(html.H5(children='Global Statistics'), class_name='text-secondary'), class_name='mt-3'),
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(html.Div('Number of hours', className='text-secondary'), width=3, class_name='border-end'),
|
|
dbc.Col(html.Div('Number of utterances', className='text-secondary'), width=3, class_name='border-end'),
|
|
dbc.Col(html.Div('Vocabulary size', className='text-secondary'), width=3, class_name='border-end'),
|
|
dbc.Col(html.Div('Alphabet size', className='text-secondary'), width=3),
|
|
],
|
|
class_name='bg-light mt-2 rounded-top border-top border-start border-end',
|
|
),
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.H5(
|
|
'{:.2f} hours'.format(num_hours),
|
|
className='text-center p-1',
|
|
style={'color': 'green', 'opacity': 0.7},
|
|
),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.H5(len(data), className='text-center p-1', style={'color': 'green', 'opacity': 0.7}),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.H5(
|
|
'{} words'.format(len(vocabulary)),
|
|
className='text-center p-1',
|
|
style={'color': 'green', 'opacity': 0.7},
|
|
),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.H5(
|
|
'{} chars'.format(len(alphabet)),
|
|
className='text-center p-1',
|
|
style={'color': 'green', 'opacity': 0.7},
|
|
),
|
|
width=3,
|
|
),
|
|
],
|
|
class_name='bg-light rounded-bottom border-bottom border-start border-end',
|
|
),
|
|
]
|
|
if metrics_available:
|
|
stats_layout += [
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.Div('Word Error Rate (WER), %', className='text-secondary'), width=3, class_name='border-end'
|
|
),
|
|
dbc.Col(
|
|
html.Div('Character Error Rate (CER), %', className='text-secondary'),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.Div('Word Match Rate (WMR), %', className='text-secondary'),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(html.Div('Mean Word Accuracy, %', className='text-secondary'), width=3),
|
|
],
|
|
class_name='bg-light mt-2 rounded-top border-top border-start border-end',
|
|
),
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.H5(
|
|
'{:.2f}'.format(wer),
|
|
className='text-center p-1',
|
|
style={'color': 'green', 'opacity': 0.7},
|
|
),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.H5(
|
|
'{:.2f}'.format(cer), className='text-center p-1', style={'color': 'green', 'opacity': 0.7}
|
|
),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.H5(
|
|
'{:.2f}'.format(wmr),
|
|
className='text-center p-1',
|
|
style={'color': 'green', 'opacity': 0.7},
|
|
),
|
|
width=3,
|
|
class_name='border-end',
|
|
),
|
|
dbc.Col(
|
|
html.H5(
|
|
'{:.2f}'.format(mwa),
|
|
className='text-center p-1',
|
|
style={'color': 'green', 'opacity': 0.7},
|
|
),
|
|
width=3,
|
|
),
|
|
],
|
|
class_name='bg-light rounded-bottom border-bottom border-start border-end',
|
|
),
|
|
]
|
|
stats_layout += [
|
|
dbc.Row(dbc.Col(html.H5(children='Alphabet'), class_name='text-secondary'), class_name='mt-3'),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
html.Div('{}'.format(sorted(alphabet))),
|
|
),
|
|
class_name='mt-2 bg-light font-monospace rounded border',
|
|
),
|
|
]
|
|
for k in figures_hist:
|
|
stats_layout += [
|
|
dbc.Row(dbc.Col(html.H5(figures_hist[k][0]), class_name='text-secondary'), class_name='mt-3'),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
dcc.Graph(id='duration-graph', figure=figures_hist[k][1]),
|
|
),
|
|
),
|
|
]
|
|
|
|
if metrics_available:
|
|
stats_layout += [
|
|
dbc.Row(dbc.Col(html.H5('Word accuracy distribution'), class_name='text-secondary'), class_name='mt-3'),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
dcc.Graph(id='word-acc-graph', figure=figure_word_acc),
|
|
),
|
|
),
|
|
]
|
|
|
|
wordstable_columns = [{'name': 'Word', 'id': 'word'}, {'name': 'Count', 'id': 'count'}]
|
|
if vocabulary and 'OOV' in vocabulary[0]:
|
|
wordstable_columns.append({'name': 'OOV', 'id': 'OOV'})
|
|
if metrics_available:
|
|
wordstable_columns.append({'name': 'Accuracy, %', 'id': 'accuracy'})
|
|
|
|
|
|
stats_layout += [
|
|
dbc.Row(dbc.Col(html.H5('Vocabulary'), class_name='text-secondary'), class_name='mt-3'),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
dash_table.DataTable(
|
|
id='wordstable',
|
|
columns=wordstable_columns,
|
|
filter_action='custom',
|
|
filter_query='',
|
|
sort_action='custom',
|
|
sort_mode='single',
|
|
page_action='custom',
|
|
page_current=0,
|
|
page_size=DATA_PAGE_SIZE,
|
|
cell_selectable=False,
|
|
page_count=math.ceil(len(vocabulary) / DATA_PAGE_SIZE),
|
|
sort_by=[{'column_id': 'word', 'direction': 'asc'}],
|
|
style_cell={'maxWidth': 0, 'textAlign': 'left'},
|
|
style_header={'color': 'text-primary'},
|
|
css=[
|
|
{'selector': '.dash-filter--case', 'rule': 'display: none'},
|
|
],
|
|
),
|
|
),
|
|
class_name='m-2',
|
|
),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
[
|
|
html.Button('Download Vocabulary', id='btn_csv'),
|
|
dcc.Download(id='download-vocab-csv'),
|
|
]
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
@app.callback(
|
|
Output('download-vocab-csv', 'data'),
|
|
[Input('btn_csv', 'n_clicks'), State('wordstable', 'sort_by'), State('wordstable', 'filter_query')],
|
|
prevent_initial_call=True,
|
|
)
|
|
def download_vocabulary(n_clicks, sort_by, filter_query):
|
|
vocabulary_view = vocabulary
|
|
filtering_expressions = filter_query.split(' && ')
|
|
for filter_part in filtering_expressions:
|
|
col_name, op, filter_value = split_filter_part(filter_part)
|
|
|
|
if op in ('eq', 'ne', 'lt', 'le', 'gt', 'ge'):
|
|
vocabulary_view = [x for x in vocabulary_view if getattr(operator, op)(x[col_name], filter_value)]
|
|
elif op == 'contains':
|
|
vocabulary_view = [x for x in vocabulary_view if filter_value in str(x[col_name])]
|
|
|
|
if len(sort_by):
|
|
col = sort_by[0]['column_id']
|
|
descending = sort_by[0]['direction'] == 'desc'
|
|
vocabulary_view = sorted(vocabulary_view, key=lambda x: x[col], reverse=descending)
|
|
|
|
with open('sde_vocab.csv', encoding='utf-8', mode='w', newline='') as fo:
|
|
writer = csv.writer(fo)
|
|
writer.writerow(vocabulary_view[0].keys())
|
|
for item in vocabulary_view:
|
|
writer.writerow([str(item[k]) for k in item])
|
|
return dcc.send_file("sde_vocab.csv")
|
|
|
|
|
|
@app.callback(
|
|
[Output('wordstable', 'data'), Output('wordstable', 'page_count')],
|
|
[Input('wordstable', 'page_current'), Input('wordstable', 'sort_by'), Input('wordstable', 'filter_query')],
|
|
)
|
|
def update_wordstable(page_current, sort_by, filter_query):
|
|
vocabulary_view = vocabulary
|
|
filtering_expressions = filter_query.split(' && ')
|
|
for filter_part in filtering_expressions:
|
|
col_name, op, filter_value = split_filter_part(filter_part)
|
|
|
|
if op in ('eq', 'ne', 'lt', 'le', 'gt', 'ge'):
|
|
vocabulary_view = [x for x in vocabulary_view if getattr(operator, op)(x[col_name], filter_value)]
|
|
elif op == 'contains':
|
|
vocabulary_view = [x for x in vocabulary_view if filter_value in str(x[col_name])]
|
|
|
|
if len(sort_by):
|
|
col = sort_by[0]['column_id']
|
|
descending = sort_by[0]['direction'] == 'desc'
|
|
vocabulary_view = sorted(vocabulary_view, key=lambda x: x[col], reverse=descending)
|
|
if page_current * DATA_PAGE_SIZE >= len(vocabulary_view):
|
|
page_current = len(vocabulary_view) // DATA_PAGE_SIZE
|
|
return [
|
|
vocabulary_view[page_current * DATA_PAGE_SIZE : (page_current + 1) * DATA_PAGE_SIZE],
|
|
math.ceil(len(vocabulary_view) / DATA_PAGE_SIZE),
|
|
]
|
|
|
|
|
|
samples_layout = [
|
|
dbc.Row(dbc.Col(html.H5('Data'), class_name='text-secondary'), class_name='mt-3'),
|
|
html.Hr(),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
dash_table.DataTable(
|
|
id='datatable',
|
|
columns=[
|
|
{'name': k.replace('_', ' '), 'id': k, 'hideable': True} for k in data[0] if not k.startswith('_')
|
|
],
|
|
filter_action='custom',
|
|
filter_query='',
|
|
sort_action='custom',
|
|
sort_mode='single',
|
|
sort_by=[],
|
|
row_selectable='single',
|
|
selected_rows=[0],
|
|
page_action='custom',
|
|
page_current=0,
|
|
page_size=DATA_PAGE_SIZE,
|
|
page_count=math.ceil(len(data) / DATA_PAGE_SIZE),
|
|
style_cell={'overflow': 'hidden', 'textOverflow': 'ellipsis', 'maxWidth': 0, 'textAlign': 'center'},
|
|
style_header={
|
|
'color': 'text-primary',
|
|
'text_align': 'center',
|
|
'height': 'auto',
|
|
'whiteSpace': 'normal',
|
|
},
|
|
css=[
|
|
{'selector': '.dash-spreadsheet-menu', 'rule': 'position:absolute; bottom: 8px'},
|
|
{'selector': '.dash-filter--case', 'rule': 'display: none'},
|
|
{'selector': '.column-header--hide', 'rule': 'display: none'},
|
|
],
|
|
),
|
|
)
|
|
),
|
|
] + [
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.Div(children=k.replace('_', ' ')),
|
|
width=2,
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
dbc.Col(html.Div(id='_' + k), class_name='mt-1 bg-light font-monospace text-break small rounded border'),
|
|
]
|
|
)
|
|
for k in data[0]
|
|
if not k.startswith('_')
|
|
]
|
|
|
|
if metrics_available:
|
|
samples_layout += [
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.Div(children='text diff'),
|
|
width=2,
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
dbc.Col(
|
|
html.Iframe(
|
|
id='_diff',
|
|
sandbox='',
|
|
srcDoc='',
|
|
style={'border': 'none', 'width': '100%', 'height': '100%'},
|
|
className='bg-light font-monospace text-break small',
|
|
),
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
]
|
|
)
|
|
]
|
|
samples_layout += [
|
|
dbc.Row(
|
|
dbc.Col(
|
|
html.Audio(id='player', controls=True),
|
|
),
|
|
class_name='mt-3 ',
|
|
),
|
|
dbc.Row(dbc.Col(dcc.Graph(id='signal-graph')), class_name='mt-3'),
|
|
]
|
|
|
|
|
|
# updating vocabulary to show
|
|
|
|
|
|
wordstable_columns_tool = [{'name': 'Word', 'id': 'word'}, {'name': 'Count', 'id': 'count'}]
|
|
wordstable_columns_tool.append({'name': 'Accuracy_1, %', 'id': 'accuracy_1'})
|
|
wordstable_columns_tool.append({'name': 'Accuracy_2, %', 'id': 'accuracy_2'})
|
|
|
|
|
|
if comparison_mode:
|
|
model_name_1, model_name_2 = name_1, name_2
|
|
|
|
for i in range(len(vocabulary_1)):
|
|
vocabulary_1[i].update(vocabulary_2[i])
|
|
|
|
def _wer_(grnd, pred):
|
|
grnd_words = grnd.split()
|
|
pred_words = pred.split()
|
|
if not grnd_words:
|
|
return 0.0
|
|
dist = edit_distance(grnd_words, pred_words)['total']
|
|
wer = dist / len(grnd_words)
|
|
return wer
|
|
|
|
def metric(a, b, met=None):
|
|
if not a:
|
|
return 0.0, 0.0
|
|
cer = edit_distance(list(a), list(b))['total'] / len(a)
|
|
wer = _wer_(a, b)
|
|
return round(float(wer) * 100, 2), round(float(cer) * 100, 2)
|
|
|
|
def write_metrics(data, Ox, Oy):
|
|
da = pd.DataFrame.from_records(data)
|
|
gt = da['text']
|
|
tt_1 = da[Ox]
|
|
tt_2 = da[Oy]
|
|
|
|
wer_tt1_c, cer_tt1_c = [], []
|
|
wer_tt2_c, cer_tt2_c = [], []
|
|
|
|
for j in range(len(gt)):
|
|
wer_tt1, cer_tt1 = metric(gt[j], tt_1[j]) # first model
|
|
wer_tt2, cer_tt2 = metric(gt[j], tt_2[j]) # second model
|
|
wer_tt1_c.append(wer_tt1)
|
|
cer_tt1_c.append(cer_tt1)
|
|
wer_tt2_c.append(wer_tt2)
|
|
cer_tt2_c.append(cer_tt2)
|
|
|
|
da['wer_' + Ox] = pd.Series(wer_tt1_c, index=da.index)
|
|
da['wer_' + Oy] = pd.Series(wer_tt2_c, index=da.index)
|
|
da['cer_' + Ox] = pd.Series(cer_tt1_c, index=da.index)
|
|
da['cer_' + Oy] = pd.Series(cer_tt2_c, index=da.index)
|
|
return da.to_dict('records')
|
|
|
|
data_with_metrics = write_metrics(data, model_name_1, model_name_2)
|
|
if args.show_statistics is not None:
|
|
textdiffstyle = {'border': 'none', 'width': '100%', 'height': '100%'}
|
|
else:
|
|
textdiffstyle = {'border': 'none', 'width': '1%', 'height': '1%', 'display': 'none'}
|
|
|
|
def prepare_data(df, name1=model_name_1, name2=model_name_2):
|
|
res = pd.DataFrame()
|
|
tmp = df['word']
|
|
res.insert(0, 'word', tmp)
|
|
res.insert(1, 'count', [float(i) for i in df['count']])
|
|
res.insert(2, 'accuracy_model_' + name1, df['accuracy_1'])
|
|
res.insert(3, 'accuracy_model_' + name2, df['accuracy_2'])
|
|
res.insert(4, 'accuracy_diff ' + '(' + name1 + ' - ' + name2 + ')', df['accuracy_1'] - df['accuracy_2'])
|
|
res.insert(2, 'count^(-1)', 1 / df['count'])
|
|
return res
|
|
|
|
for_col_names = pd.DataFrame()
|
|
for_col_names.insert(0, 'word', ['a'])
|
|
for_col_names.insert(1, 'count', [0])
|
|
for_col_names.insert(2, 'accuracy_model_' + model_name_1, [0])
|
|
for_col_names.insert(3, 'accuracy_model_' + model_name_2, [0])
|
|
for_col_names.insert(4, 'accuracy_diff ' + '(' + model_name_1 + ' - ' + model_name_2 + ')', [0])
|
|
for_col_names.insert(5, 'count^(-1)', [0])
|
|
|
|
@app.callback(
|
|
Output('voc_graph', 'figure'),
|
|
[
|
|
Input('xaxis-column', 'value'),
|
|
Input('yaxis-column', 'value'),
|
|
Input('color-column', 'value'),
|
|
Input('size-column', 'value'),
|
|
Input("datatable-advanced-filtering", "derived_virtual_data"),
|
|
Input("dot_spacing", 'value'),
|
|
Input("radius", 'value'),
|
|
],
|
|
prevent_initial_call=False,
|
|
)
|
|
def draw_vocab(Ox, Oy, color, size, data, dot_spacing='no', rad=0.01):
|
|
import math
|
|
import random
|
|
|
|
import pandas as pd
|
|
|
|
df = pd.DataFrame.from_records(data)
|
|
|
|
res = prepare_data(df)
|
|
res_spacing = res.copy(deep=True)
|
|
|
|
if dot_spacing == 'yes':
|
|
rad = float(rad)
|
|
if Ox[0] in ('a', 'c'):
|
|
tmp = []
|
|
for i in range(len(res[Ox])):
|
|
tmp.append(
|
|
res[Ox][i]
|
|
+ rad
|
|
* random.randrange(1, 10)
|
|
* math.cos(random.randrange(1, len(res[Ox])) * 2 * math.pi / len(res[Ox]))
|
|
)
|
|
res_spacing[Ox] = tmp
|
|
if Ox[0] in ('a', 'c'):
|
|
tmp = []
|
|
for i in range(len(res[Oy])):
|
|
tmp.append(
|
|
res[Oy][i]
|
|
+ rad
|
|
* random.randrange(1, 10)
|
|
* math.sin(random.randrange(1, len(res[Oy])) * 2 * math.pi / len(res[Oy]))
|
|
)
|
|
res_spacing[Oy] = tmp
|
|
|
|
res = res_spacing
|
|
|
|
fig = px.scatter(
|
|
res,
|
|
x=Ox,
|
|
y=Oy,
|
|
color=color,
|
|
size=size,
|
|
hover_data={'word': True, Ox: True, Oy: True, 'count': True},
|
|
width=1300,
|
|
height=1000,
|
|
)
|
|
if (Ox == 'accuracy_model_' + model_name_1 and Oy == 'accuracy_model_' + model_name_2) or (
|
|
Oy == 'accuracy_model_' + model_name_1 and Ox == 'accuracy_model_' + model_name_2
|
|
):
|
|
fig.add_shape(
|
|
type="line",
|
|
x0=0,
|
|
y0=0,
|
|
x1=100,
|
|
y1=100,
|
|
line=dict(
|
|
color="MediumPurple",
|
|
width=1,
|
|
dash="dot",
|
|
),
|
|
)
|
|
|
|
return fig
|
|
|
|
@app.callback(
|
|
Output('filter-query-input', 'style'),
|
|
Output('filter-query-output', 'style'),
|
|
Input('filter-query-read-write', 'value'),
|
|
)
|
|
def query_input_output(val):
|
|
input_style = {'width': '100%'}
|
|
output_style = {}
|
|
input_style.update(display='inline-block')
|
|
output_style.update(display='none')
|
|
return input_style, output_style
|
|
|
|
@app.callback(Output('datatable-advanced-filtering', 'filter_query'), Input('filter-query-input', 'value'))
|
|
def write_query(query):
|
|
if query is None:
|
|
return ''
|
|
return query
|
|
|
|
@app.callback(Output('filter-query-output', 'children'), Input('datatable-advanced-filtering', 'filter_query'))
|
|
def read_query(query):
|
|
if query is None:
|
|
return "No filter query"
|
|
return dcc.Markdown('`filter_query = "{}"`'.format(query))
|
|
|
|
############
|
|
@app.callback(
|
|
Output('filter-query-input-2', 'style'),
|
|
Output('filter-query-output-2', 'style'),
|
|
Input('filter-query-read-write', 'value'),
|
|
)
|
|
def query_input_output(val):
|
|
input_style = {'width': '100%'}
|
|
output_style = {}
|
|
input_style.update(display='inline-block')
|
|
output_style.update(display='none')
|
|
return input_style, output_style
|
|
|
|
@app.callback(Output('datatable-advanced-filtering-2', 'filter_query'), Input('filter-query-input-2', 'value'))
|
|
def write_query(query):
|
|
if query is None:
|
|
return ''
|
|
return query
|
|
|
|
@app.callback(Output('filter-query-output-2', 'children'), Input('datatable-advanced-filtering-2', 'filter_query'))
|
|
def read_query(query):
|
|
if query is None:
|
|
return "No filter query"
|
|
return dcc.Markdown('`filter_query = "{}"`'.format(query))
|
|
|
|
############
|
|
|
|
def display_query(query):
|
|
if query is None:
|
|
return ''
|
|
return html.Details(
|
|
[
|
|
html.Summary('Derived filter query structure'),
|
|
html.Div(
|
|
dcc.Markdown(
|
|
'''```json
|
|
{}
|
|
```'''.format(
|
|
json.dumps(query, indent=4)
|
|
)
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
comparison_layout = [
|
|
html.Div(
|
|
[
|
|
dcc.Markdown("model 1:" + ' ' + model_name_1[10:]),
|
|
dcc.Markdown("model 2:" + ' ' + model_name_2[10:]),
|
|
dcc.Dropdown(
|
|
['word level', 'utterance level'],
|
|
'word level',
|
|
placeholder="choose comparison lvl",
|
|
id='lvl_choose',
|
|
),
|
|
]
|
|
),
|
|
html.Hr(),
|
|
html.Div(
|
|
[
|
|
html.Div(
|
|
[
|
|
dcc.Dropdown(for_col_names.columns[::], 'accuracy_model_' + model_name_1, id='xaxis-column'),
|
|
dcc.Dropdown(for_col_names.columns[::], 'accuracy_model_' + model_name_2, id='yaxis-column'),
|
|
dcc.Dropdown(
|
|
for_col_names.select_dtypes(include='number').columns[::],
|
|
placeholder='Select what will encode color of points',
|
|
id='color-column',
|
|
),
|
|
dcc.Dropdown(
|
|
for_col_names.select_dtypes(include='number').columns[::],
|
|
placeholder='Select what will encode size of points',
|
|
id='size-column',
|
|
),
|
|
dcc.Dropdown(
|
|
['yes', 'no'],
|
|
placeholder='if you want to enable dot spacing',
|
|
id='dot_spacing',
|
|
style={'width': '200%'},
|
|
),
|
|
dcc.Input(id='radius', placeholder='Enter radius of spacing (std is 0.01)'),
|
|
html.Hr(),
|
|
dcc.Input(
|
|
id='filter-query-input',
|
|
placeholder='Enter filter query',
|
|
),
|
|
],
|
|
style={'width': '200%', 'display': 'inline-block', 'float': 'middle'},
|
|
),
|
|
html.Hr(),
|
|
html.Div(id='filter-query-output'),
|
|
dash_table.DataTable(
|
|
id='datatable-advanced-filtering',
|
|
columns=wordstable_columns_tool,
|
|
data=vocabulary_1,
|
|
editable=False,
|
|
page_action='native',
|
|
page_size=5,
|
|
filter_action="native",
|
|
),
|
|
html.Hr(),
|
|
html.Div(id='datatable-query-structure', style={'whitespace': 'pre'}),
|
|
html.Hr(),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
dcc.Graph(id='voc_graph'),
|
|
),
|
|
),
|
|
html.Hr(),
|
|
],
|
|
id='wrd_lvl',
|
|
style={'display': 'block'},
|
|
),
|
|
html.Div(
|
|
[
|
|
html.Div(
|
|
[
|
|
dcc.Dropdown(['WER', 'CER'], 'WER', placeholder="Choose metric", id="choose_metric"),
|
|
dbc.Row(dbc.Col(html.H5('Data'), class_name='text-secondary'), class_name='mt-3'),
|
|
html.Hr(),
|
|
html.Hr(),
|
|
dcc.Input(
|
|
id='filter-query-input-2', placeholder='Enter filter query', style={'width': '100%'}
|
|
),
|
|
html.Div(id='filter-query-output-2'),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
[
|
|
dash_table.DataTable(
|
|
id='datatable-advanced-filtering-2',
|
|
columns=[
|
|
{'name': k.replace('_', ' '), 'id': k, 'hideable': True}
|
|
for k in data_with_metrics[0]
|
|
],
|
|
data=data_with_metrics,
|
|
editable=False,
|
|
page_action='native',
|
|
page_size=5,
|
|
row_selectable='single',
|
|
selected_rows=[0],
|
|
page_current=0,
|
|
filter_action="native",
|
|
style_cell={
|
|
'overflow': 'hidden',
|
|
'textOverflow': 'ellipsis',
|
|
'maxWidth': 0,
|
|
'textAlign': 'center',
|
|
},
|
|
style_header={
|
|
'color': 'text-primary',
|
|
'text_align': 'center',
|
|
'height': 'auto',
|
|
'whiteSpace': 'normal',
|
|
},
|
|
css=[
|
|
{
|
|
'selector': '.dash-spreadsheet-menu',
|
|
'rule': 'position:absolute; bottom: 8px',
|
|
},
|
|
{'selector': '.dash-filter--case', 'rule': 'display: none'},
|
|
{'selector': '.column-header--hide', 'rule': 'display: none'},
|
|
],
|
|
),
|
|
dbc.Row(
|
|
dbc.Col(
|
|
html.Audio(id='player-1', controls=True),
|
|
),
|
|
class_name='mt-3',
|
|
),
|
|
]
|
|
)
|
|
),
|
|
]
|
|
+ [
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.Div(children=k.replace('_', '-')),
|
|
width=2,
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
dbc.Col(
|
|
html.Div(id='__' + k),
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
]
|
|
)
|
|
for k in data_with_metrics[0]
|
|
]
|
|
),
|
|
],
|
|
id='unt_lvl',
|
|
),
|
|
] + [
|
|
html.Div(
|
|
[
|
|
html.Div(
|
|
[
|
|
dbc.Row(
|
|
dbc.Col(
|
|
dcc.Graph(id='utt_graph'),
|
|
),
|
|
),
|
|
html.Hr(),
|
|
dcc.Input(id='clicked_aidopath', style={'width': '100%'}),
|
|
html.Hr(),
|
|
dcc.Input(id='my-output-1', style={'display': 'none'}), # we do need this
|
|
]
|
|
),
|
|
html.Div(
|
|
[
|
|
dbc.Row(dbc.Col(dcc.Graph(id='signal-graph-1')), class_name='mt-3'),
|
|
]
|
|
),
|
|
],
|
|
id='down_thing',
|
|
style={'display': 'block'},
|
|
)
|
|
]
|
|
|
|
if args.show_statistics is not None:
|
|
comparison_layout += [
|
|
html.Div(
|
|
[
|
|
dbc.Row(
|
|
[
|
|
dbc.Col(
|
|
html.Div(children='text diff'),
|
|
width=2,
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
dbc.Col(
|
|
html.Iframe(
|
|
id='__diff',
|
|
sandbox='',
|
|
srcDoc='',
|
|
style=textdiffstyle,
|
|
className='bg-light font-monospace text-break small',
|
|
),
|
|
class_name='mt-1 bg-light font-monospace text-break small rounded border',
|
|
),
|
|
],
|
|
id="text_diff_div",
|
|
)
|
|
],
|
|
id='mid_thing',
|
|
style={'display': 'block'},
|
|
),
|
|
]
|
|
|
|
@app.callback(
|
|
[
|
|
Output(component_id='wrd_lvl', component_property='style'),
|
|
Output(component_id='unt_lvl', component_property='style'),
|
|
Output(component_id='mid_thing', component_property='style'),
|
|
Output(component_id='down_thing', component_property='style'),
|
|
Input(component_id='lvl_choose', component_property='value'),
|
|
]
|
|
)
|
|
def show_hide_element(visibility_state):
|
|
if visibility_state == 'word level':
|
|
return (
|
|
{'width': '50%', 'display': 'inline-block', 'float': 'middle'},
|
|
{'width': '50%', 'display': 'none', 'float': 'middle'},
|
|
{'display': 'none'},
|
|
{'display': 'none'},
|
|
)
|
|
else:
|
|
return (
|
|
{'width': '100%', 'display': 'none', 'float': 'middle'},
|
|
{'width': '100%', 'display': 'inline-block', 'float': 'middle'},
|
|
{'display': 'block'},
|
|
{'display': 'block'},
|
|
)
|
|
|
|
|
|
if args.show_statistics is None:
|
|
|
|
@app.callback(
|
|
[
|
|
Output(component_id='wrd_lvl', component_property='style'),
|
|
Output(component_id='unt_lvl', component_property='style'),
|
|
Output(component_id='down_thing', component_property='style'),
|
|
Input(component_id='lvl_choose', component_property='value'),
|
|
]
|
|
)
|
|
def show_hide_element(visibility_state):
|
|
if args.show_statistics is not None:
|
|
a = {'border': 'none', 'width': '100%', 'height': '100%', 'display': 'block'}
|
|
else:
|
|
a = {'border': 'none', 'width': '100%', 'height': '100%', 'display': 'none'}
|
|
if visibility_state == 'word level':
|
|
return (
|
|
{'width': '50%', 'display': 'inline-block', 'float': 'middle'},
|
|
{'width': '50%', 'display': 'none', 'float': 'middle'},
|
|
{'display': 'none'},
|
|
)
|
|
else:
|
|
return (
|
|
{'width': '100%', 'display': 'none', 'float': 'middle'},
|
|
{'width': '100%', 'display': 'inline-block', 'float': 'middle'},
|
|
{'display': 'block'},
|
|
)
|
|
|
|
|
|
store = []
|
|
|
|
|
|
@app.callback(
|
|
[Output('datatable-advanced-filtering-2', 'page_current'), Output('my-output-1', 'value')],
|
|
[
|
|
Input('utt_graph', 'clickData'),
|
|
],
|
|
)
|
|
def real_select_click(hoverData):
|
|
if hoverData is not None:
|
|
path = str(hoverData['points'][0]['customdata'][-1])
|
|
for t in range(len(data_with_metrics)):
|
|
if data_with_metrics[t]['audio_filepath'] == path:
|
|
ind = t
|
|
s = t # % 5
|
|
sel = s
|
|
pg = math.ceil(ind // 5)
|
|
return pg, sel
|
|
else:
|
|
return 0, 0
|
|
|
|
|
|
@app.callback(
|
|
[Output('datatable-advanced-filtering-2', 'selected_rows')],
|
|
[Input('my-output-1', 'value')],
|
|
)
|
|
def real_select_click(num):
|
|
s = num
|
|
return [[s]]
|
|
|
|
|
|
CALCULATED_METRIC = [False, False]
|
|
|
|
|
|
@app.callback(
|
|
[
|
|
Output('utt_graph', 'figure'),
|
|
Output('clicked_aidopath', 'value'),
|
|
Input('choose_metric', 'value'),
|
|
Input('utt_graph', 'clickData'),
|
|
Input('datatable-advanced-filtering-2', 'derived_virtual_data'),
|
|
],
|
|
)
|
|
def draw_table_with_metrics(met, hoverData, data_virt):
|
|
Ox = name_1
|
|
Oy = name_2
|
|
if met == "WER":
|
|
cerower = 'wer_'
|
|
else:
|
|
cerower = 'cer_'
|
|
da = pd.DataFrame.from_records(data_virt)
|
|
|
|
c = da
|
|
fig = px.scatter(
|
|
c,
|
|
x=cerower + Ox,
|
|
y=cerower + Oy,
|
|
width=1000,
|
|
height=900,
|
|
color='num_words',
|
|
hover_data={
|
|
'text': True,
|
|
Ox: True,
|
|
Oy: True,
|
|
'wer_' + Ox: True,
|
|
'wer_' + Oy: True,
|
|
'cer_' + Ox: True,
|
|
'cer_' + Oy: True,
|
|
'audio_filepath': True,
|
|
},
|
|
) #'numwords': True,
|
|
fig.add_shape(
|
|
type="line",
|
|
x0=0,
|
|
y0=0,
|
|
x1=100,
|
|
y1=100,
|
|
line=dict(
|
|
color="Red",
|
|
width=1,
|
|
dash="dot",
|
|
),
|
|
)
|
|
fig.update_layout(clickmode='event+select')
|
|
fig.update_traces(marker_size=10)
|
|
path = None
|
|
|
|
if hoverData is not None:
|
|
path = str(hoverData['points'][0]['customdata'][-1])
|
|
|
|
return fig, path
|
|
|
|
|
|
@app.callback(
|
|
[Output('datatable', 'data'), Output('datatable', 'page_count')],
|
|
[Input('datatable', 'page_current'), Input('datatable', 'sort_by'), Input('datatable', 'filter_query')],
|
|
)
|
|
def update_datatable(page_current, sort_by, filter_query):
|
|
data_view = data
|
|
filtering_expressions = filter_query.split(' && ')
|
|
for filter_part in filtering_expressions:
|
|
col_name, op, filter_value = split_filter_part(filter_part)
|
|
|
|
if op in ('eq', 'ne', 'lt', 'le', 'gt', 'ge'):
|
|
data_view = [x for x in data_view if getattr(operator, op)(x[col_name], filter_value)]
|
|
elif op == 'contains':
|
|
data_view = [x for x in data_view if filter_value in str(x[col_name])]
|
|
|
|
if len(sort_by):
|
|
col = sort_by[0]['column_id']
|
|
descending = sort_by[0]['direction'] == 'desc'
|
|
data_view = sorted(data_view, key=lambda x: x[col], reverse=descending)
|
|
if page_current * DATA_PAGE_SIZE >= len(data_view):
|
|
page_current = len(data_view) // DATA_PAGE_SIZE
|
|
return [
|
|
data_view[page_current * DATA_PAGE_SIZE : (page_current + 1) * DATA_PAGE_SIZE],
|
|
math.ceil(len(data_view) / DATA_PAGE_SIZE),
|
|
]
|
|
|
|
|
|
if comparison_mode:
|
|
app.layout = html.Div(
|
|
[
|
|
dcc.Location(id='url', refresh=False),
|
|
dbc.NavbarSimple(
|
|
children=[
|
|
dbc.NavItem(dbc.NavLink('Statistics', id='stats_link', href='/', active=True)),
|
|
dbc.NavItem(dbc.NavLink('Samples', id='samples_link', href='/samples')),
|
|
dbc.NavItem(dbc.NavLink('Comparison tool', id='comp_tool', href='/comparison')),
|
|
],
|
|
brand='Speech Data Explorer',
|
|
sticky='top',
|
|
color='green',
|
|
dark=True,
|
|
),
|
|
dbc.Container(id='page-content'),
|
|
]
|
|
)
|
|
else:
|
|
app.layout = html.Div(
|
|
[
|
|
dcc.Location(id='url', refresh=False),
|
|
dbc.NavbarSimple(
|
|
children=[
|
|
dbc.NavItem(dbc.NavLink('Statistics', id='stats_link', href='/', active=True)),
|
|
dbc.NavItem(dbc.NavLink('Samples', id='samples_link', href='/samples')),
|
|
],
|
|
brand='Speech Data Explorer',
|
|
sticky='top',
|
|
color='green',
|
|
dark=True,
|
|
),
|
|
dbc.Container(id='page-content'),
|
|
]
|
|
)
|
|
|
|
|
|
if comparison_mode:
|
|
|
|
@app.callback(
|
|
[
|
|
Output('page-content', 'children'),
|
|
Output('stats_link', 'active'),
|
|
Output('samples_link', 'active'),
|
|
Output('comp_tool', 'active'),
|
|
],
|
|
[Input('url', 'pathname')],
|
|
)
|
|
def nav_click(url):
|
|
if url == '/samples':
|
|
return [samples_layout, False, True, False]
|
|
elif url == '/comparison':
|
|
return [comparison_layout, False, False, True]
|
|
else:
|
|
return [stats_layout, True, False, False]
|
|
|
|
else:
|
|
|
|
@app.callback(
|
|
[
|
|
Output('page-content', 'children'),
|
|
Output('stats_link', 'active'),
|
|
Output('samples_link', 'active'),
|
|
],
|
|
[Input('url', 'pathname')],
|
|
)
|
|
def nav_click(url):
|
|
if url == '/samples':
|
|
return [samples_layout, False, True]
|
|
else:
|
|
return [stats_layout, True, False]
|
|
|
|
|
|
@app.callback(
|
|
[Output('_' + k, 'children') for k in data[0] if not k.startswith('_')],
|
|
[Input('datatable', 'selected_rows'), Input('datatable', 'data')],
|
|
)
|
|
def show_item(idx, data):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
return [data[idx[0]][k] for k in data[0] if not k.startswith('_')]
|
|
|
|
|
|
if comparison_mode:
|
|
|
|
@app.callback(
|
|
[Output('__' + k, 'children') for k in data_with_metrics[0]],
|
|
[Input('datatable-advanced-filtering-2', 'selected_rows'), Input('datatable-advanced-filtering-2', 'data')],
|
|
)
|
|
def show_item(idx, data):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
return [data[idx[0]][k] for k in data_with_metrics[0]]
|
|
|
|
|
|
@app.callback(
|
|
Output('_diff', 'srcDoc'),
|
|
[
|
|
Input('datatable', 'selected_rows'),
|
|
Input('datatable', 'data'),
|
|
],
|
|
)
|
|
def show_diff(
|
|
idx,
|
|
data,
|
|
):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
orig_words = data[idx[0]]['text']
|
|
orig_words = '\n'.join(orig_words.split()) + '\n'
|
|
|
|
pred_words = data[idx[0]][fld_nm]
|
|
pred_words = '\n'.join(pred_words.split()) + '\n'
|
|
|
|
diff = diff_match_patch.diff_match_patch()
|
|
diff.Diff_Timeout = 0
|
|
orig_enc, pred_enc, enc = diff.diff_linesToChars(orig_words, pred_words)
|
|
diffs = diff.diff_main(orig_enc, pred_enc, False)
|
|
diff.diff_charsToLines(diffs, enc)
|
|
diffs_post = []
|
|
for d in diffs:
|
|
diffs_post.append((d[0], d[1].replace('\n', ' ')))
|
|
|
|
diff_html = diff.diff_prettyHtml(diffs_post)
|
|
|
|
return diff_html
|
|
|
|
|
|
@app.callback(
|
|
Output('__diff', 'srcDoc'),
|
|
[
|
|
Input('datatable-advanced-filtering-2', 'selected_rows'),
|
|
Input('datatable-advanced-filtering-2', 'data'),
|
|
],
|
|
)
|
|
def show_diff(
|
|
idx,
|
|
data,
|
|
):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
orig_words = data[idx[0]]['text']
|
|
orig_words = '\n'.join(orig_words.split()) + '\n'
|
|
|
|
pred_words = data[idx[0]][fld_nm]
|
|
pred_words = '\n'.join(pred_words.split()) + '\n'
|
|
|
|
diff = diff_match_patch.diff_match_patch()
|
|
diff.Diff_Timeout = 0
|
|
orig_enc, pred_enc, enc = diff.diff_linesToChars(orig_words, pred_words)
|
|
diffs = diff.diff_main(orig_enc, pred_enc, False)
|
|
diff.diff_charsToLines(diffs, enc)
|
|
diffs_post = []
|
|
for d in diffs:
|
|
diffs_post.append((d[0], d[1].replace('\n', ' ')))
|
|
|
|
diff_html = diff.diff_prettyHtml(diffs_post)
|
|
|
|
return diff_html
|
|
|
|
|
|
@app.callback(Output('signal-graph', 'figure'), [Input('datatable', 'selected_rows'), Input('datatable', 'data')])
|
|
def plot_signal(idx, data):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
figs = make_subplots(rows=2, cols=1, subplot_titles=('Waveform', 'Spectrogram'))
|
|
try:
|
|
tar_path = data[idx[0]].get('_tar_path')
|
|
audio, fs = load_audio_data(
|
|
data[idx[0]]['audio_filepath'], args.audio_base_path, tar_path, args.dali_index_base
|
|
)
|
|
if 'offset' in data[idx[0]]:
|
|
audio = audio[
|
|
int(data[idx[0]]['offset'] * fs) : int((data[idx[0]]['offset'] + data[idx[0]]['duration']) * fs)
|
|
]
|
|
time_stride = 0.01
|
|
hop_length = int(fs * time_stride)
|
|
n_fft = 512
|
|
# linear scale spectrogram
|
|
s = librosa.stft(y=audio, n_fft=n_fft, hop_length=hop_length)
|
|
s_db = librosa.power_to_db(S=np.abs(s) ** 2, ref=np.max, top_db=100)
|
|
figs.add_trace(
|
|
go.Scatter(
|
|
x=np.arange(audio.shape[0]) / fs,
|
|
y=audio,
|
|
line={'color': 'green'},
|
|
name='Waveform',
|
|
hovertemplate='Time: %{x:.2f} s<br>Amplitude: %{y:.2f}<br><extra></extra>',
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
figs.add_trace(
|
|
go.Heatmap(
|
|
z=s_db,
|
|
colorscale=[
|
|
[0, 'rgb(30,62,62)'],
|
|
[0.5, 'rgb(30,128,128)'],
|
|
[1, 'rgb(30,255,30)'],
|
|
],
|
|
colorbar=dict(yanchor='middle', lenmode='fraction', y=0.2, len=0.5, ticksuffix=' dB'),
|
|
dx=time_stride,
|
|
dy=fs / n_fft / 1000,
|
|
name='Spectrogram',
|
|
hovertemplate='Time: %{x:.2f} s<br>Frequency: %{y:.2f} kHz<br>Magnitude: %{z:.2f} dB<extra></extra>',
|
|
),
|
|
row=2,
|
|
col=1,
|
|
)
|
|
figs.update_layout({'margin': dict(l=0, r=0, t=20, b=0, pad=0), 'height': 500})
|
|
figs.update_xaxes(title_text='Time, s', row=1, col=1)
|
|
figs.update_yaxes(title_text='Amplitude', row=1, col=1)
|
|
figs.update_xaxes(title_text='Time, s', row=2, col=1)
|
|
figs.update_yaxes(title_text='Frequency, kHz', row=2, col=1)
|
|
except Exception as ex:
|
|
app.logger.error(f'ERROR in plot signal: {ex}')
|
|
|
|
return figs
|
|
|
|
|
|
@app.callback(
|
|
Output('signal-graph-1', 'figure'),
|
|
[Input('datatable-advanced-filtering-2', 'selected_rows'), Input('datatable-advanced-filtering-2', 'data')],
|
|
)
|
|
def plot_signal(idx, data):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
figs = make_subplots(rows=2, cols=1, subplot_titles=('Waveform', 'Spectrogram'))
|
|
try:
|
|
tar_path = data[idx[0]].get('_tar_path')
|
|
audio, fs = load_audio_data(
|
|
data[idx[0]]['audio_filepath'], args.audio_base_path, tar_path, args.dali_index_base
|
|
)
|
|
if 'offset' in data[idx[0]]:
|
|
audio = audio[
|
|
int(data[idx[0]]['offset'] * fs) : int((data[idx[0]]['offset'] + data[idx[0]]['duration']) * fs)
|
|
]
|
|
time_stride = 0.01
|
|
hop_length = int(fs * time_stride)
|
|
n_fft = 512
|
|
# linear scale spectrogram
|
|
s = librosa.stft(y=audio, n_fft=n_fft, hop_length=hop_length)
|
|
s_db = librosa.power_to_db(S=np.abs(s) ** 2, ref=np.max, top_db=100)
|
|
figs.add_trace(
|
|
go.Scatter(
|
|
x=np.arange(audio.shape[0]) / fs,
|
|
y=audio,
|
|
line={'color': 'green'},
|
|
name='Waveform',
|
|
hovertemplate='Time: %{x:.2f} s<br>Amplitude: %{y:.2f}<br><extra></extra>',
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
figs.add_trace(
|
|
go.Heatmap(
|
|
z=s_db,
|
|
colorscale=[
|
|
[0, 'rgb(30,62,62)'],
|
|
[0.5, 'rgb(30,128,128)'],
|
|
[1, 'rgb(30,255,30)'],
|
|
],
|
|
colorbar=dict(yanchor='middle', lenmode='fraction', y=0.2, len=0.5, ticksuffix=' dB'),
|
|
dx=time_stride,
|
|
dy=fs / n_fft / 1000,
|
|
name='Spectrogram',
|
|
hovertemplate='Time: %{x:.2f} s<br>Frequency: %{y:.2f} kHz<br>Magnitude: %{z:.2f} dB<extra></extra>',
|
|
),
|
|
row=2,
|
|
col=1,
|
|
)
|
|
figs.update_layout({'margin': dict(l=0, r=0, t=20, b=0, pad=0), 'height': 500})
|
|
figs.update_xaxes(title_text='Time, s', row=1, col=1)
|
|
figs.update_yaxes(title_text='Amplitude', row=1, col=1)
|
|
figs.update_xaxes(title_text='Time, s', row=2, col=1)
|
|
figs.update_yaxes(title_text='Frequency, kHz', row=2, col=1)
|
|
except Exception as ex:
|
|
app.logger.error(f'ERROR in plot signal: {ex}')
|
|
|
|
return figs
|
|
|
|
|
|
@app.callback(Output('player', 'src'), [Input('datatable', 'selected_rows'), Input('datatable', 'data')])
|
|
def update_player(idx, data):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
try:
|
|
tar_path = data[idx[0]].get('_tar_path')
|
|
signal, sr = load_audio_data(
|
|
data[idx[0]]['audio_filepath'], args.audio_base_path, tar_path, args.dali_index_base
|
|
)
|
|
if 'offset' in data[idx[0]]:
|
|
signal = signal[
|
|
int(data[idx[0]]['offset'] * sr) : int((data[idx[0]]['offset'] + data[idx[0]]['duration']) * sr)
|
|
]
|
|
with io.BytesIO() as buf:
|
|
# convert to PCM .wav
|
|
sf.write(buf, signal, sr, format='WAV')
|
|
buf.seek(0)
|
|
encoded = base64.b64encode(buf.read())
|
|
return 'data:audio/wav;base64,{}'.format(encoded.decode())
|
|
except Exception as ex:
|
|
app.logger.error(f'ERROR in audio player: {ex}')
|
|
return ''
|
|
|
|
|
|
@app.callback(
|
|
Output('player-1', 'src'),
|
|
[Input('datatable-advanced-filtering-2', 'selected_rows'), Input('datatable-advanced-filtering-2', 'data')],
|
|
)
|
|
def update_player(idx, data):
|
|
if len(idx) == 0:
|
|
raise PreventUpdate
|
|
try:
|
|
tar_path = data[idx[0]].get('_tar_path')
|
|
signal, sr = load_audio_data(
|
|
data[idx[0]]['audio_filepath'], args.audio_base_path, tar_path, args.dali_index_base
|
|
)
|
|
if 'offset' in data[idx[0]]:
|
|
signal = signal[
|
|
int(data[idx[0]]['offset'] * sr) : int((data[idx[0]]['offset'] + data[idx[0]]['duration']) * sr)
|
|
]
|
|
with io.BytesIO() as buf:
|
|
# convert to PCM .wav
|
|
sf.write(buf, signal, sr, format='WAV')
|
|
buf.seek(0)
|
|
encoded = base64.b64encode(buf.read())
|
|
return 'data:audio/wav;base64,{}'.format(encoded.decode())
|
|
except Exception as ex:
|
|
app.logger.error(f'ERROR in audio player: {ex}')
|
|
return ''
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=args.port, debug=args.debug)
|