# Copyright 2023 The Magenta Authors.
#
# 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.

"""Utilities for fine alignment.

CQT calculations and NoteSequence manipulations are done in Python. For speed,
DTW calculations are done in C++ by calling the 'align' program, which is
specifically intended to be used with this library. Communication between
Python and C++ is done with a protobuf.
"""

import os
import subprocess
import tempfile

from absl import logging
import alignment_pb2
import librosa
from note_seq import midi_synth
from note_seq import sequences_lib
import numpy as np


# Constants based on craffel's example alignment script:
# https://github.com/craffel/pretty-midi/blob/main/examples/align_midi.py

SAMPLE_RATE = 22050
CQT_HOP_LENGTH_FINE = 64  # ~3ms
CQT_N_BINS = 48
CQT_BINS_PER_OCTAVE = 12
CQT_FMIN = librosa.midi_to_hz(36)

ALIGN_BINARY = './align'


def extract_cqt(samples, sample_rate, cqt_hop_length):
  """Transforms the contents of a wav/mp3 file into a series of CQT frames."""
  cqt = np.abs(librosa.core.cqt(
      samples,
      sample_rate,
      hop_length=cqt_hop_length,
      fmin=CQT_FMIN,
      n_bins=CQT_N_BINS,
      bins_per_octave=CQT_BINS_PER_OCTAVE), dtype=np.float32)

  # Compute log-amplitude
  cqt = librosa.power_to_db(cqt)
  return cqt


def align_cpp(samples,
              sample_rate,
              ns,
              cqt_hop_length,
              sf2_path,
              penalty_mul=1.0,
              band_radius_seconds=.5):
  """Aligns the notesequence to the wav file using C++ DTW.

  Args:
    samples: Samples to align.
    sample_rate: Sample rate for samples.
    ns: The source notesequence to align.
    cqt_hop_length: Hop length to use for CQT calculations.
    sf2_path: Path to SF2 file for synthesis.
    penalty_mul: Penalty multiplier to use for non-diagonal moves.
    band_radius_seconds: What size of band radius to use for restricting DTW.

  Raises:
    RuntimeError: If notes are skipped during alignment.

  Returns:
    samples: The samples used from the wav file.
    aligned_ns: The aligned version of the notesequence.
    remaining_ns: Any remaining notesequence that extended beyond the length
        of the wav file.
  """
  logging.info('Synthesizing')
  ns_samples = midi_synth.fluidsynth(
      ns, sf2_path=sf2_path, sample_rate=sample_rate).astype(np.float32)

  # It is critical that ns_samples and samples are the same length because the
  # alignment code does not do subsequence alignment.
  ns_samples = np.pad(ns_samples,
                      (0, max(0, samples.shape[0] - ns_samples.shape[0])),
                      'constant')

  # Pad samples too, if needed, because there are some cases where the
  # synthesized NoteSequence is actually longer.
  samples = np.pad(samples,
                   (0, max(0, ns_samples.shape[0] - samples.shape[0])),
                   'constant')

  # Note that we skip normalization here becasue it happens in C++.
  logging.info('source_cqt')
  source_cqt = extract_cqt(ns_samples, sample_rate, cqt_hop_length)

  logging.info('dest_cqt')
  dest_cqt = extract_cqt(samples, sample_rate, cqt_hop_length)

  alignment_task = alignment_pb2.AlignmentTask()
  alignment_task.sequence_1.x = source_cqt.shape[0]
  alignment_task.sequence_1.y = source_cqt.shape[1]
  for c in source_cqt.reshape([-1]):
    alignment_task.sequence_1.content.append(c)

  alignment_task.sequence_2.x = dest_cqt.shape[0]
  alignment_task.sequence_2.y = dest_cqt.shape[1]
  for c in dest_cqt.reshape([-1]):
    alignment_task.sequence_2.content.append(c)

  seconds_per_frame = cqt_hop_length / sample_rate

  alignment_task.band_radius = int(band_radius_seconds / seconds_per_frame)
  alignment_task.penalty = 0
  alignment_task.penalty_mul = penalty_mul

  # Write to file.
  fh, temp_path = tempfile.mkstemp(suffix='.proto')
  os.close(fh)
  with open(temp_path, 'wb') as f:
    f.write(alignment_task.SerializeToString())

  # Align with C++ program.
  subprocess.check_call([ALIGN_BINARY, temp_path])

  # Read file.
  with open(temp_path + '.result', 'rb') as f:
    result = alignment_pb2.AlignmentResult.FromString(f.read())

  # Clean up.
  os.remove(temp_path)
  os.remove(temp_path + '.result')

  logging.info('Aligning NoteSequence with warp path.')

  warp_seconds_i = np.array([i * seconds_per_frame for i in result.i])
  warp_seconds_j = np.array([j * seconds_per_frame for j in result.j])

  time_diffs = np.abs(warp_seconds_i - warp_seconds_j)
  warps = np.abs(time_diffs[1:] - time_diffs[:-1])

  stats = {
      'alignment_score': result.score,
      'warp_mean_s': np.mean(warps),
      'warp_median_s': np.median(warps),
      'warp_max_s': np.max(warps),
      'warp_min_s': np.min(warps),
      'time_diff_mean_s': np.mean(time_diffs),
      'time_diff_median_s': np.median(time_diffs),
      'time_diff_max_s': np.max(time_diffs),
      'time_diff_min_s': np.min(time_diffs),
  }

  for name, value in sorted(stats.items()):
    logging.info('%s: %f', name, value)

  aligned_ns, skipped_notes = sequences_lib.adjust_notesequence_times(
      ns,
      lambda t: np.interp(t, warp_seconds_i, warp_seconds_j),
      minimum_duration=seconds_per_frame)
  if skipped_notes > 0:
    raise RuntimeError('Skipped {} notes'.format(skipped_notes))

  logging.debug('done')

  return aligned_ns, stats
