musicvis-lib
Version:
Music analysis and visualization library
205 lines (193 loc) • 5.42 kB
JavaScript
import { arraySlicesEqual } from '../utils/ArrayUtils.js'
/**
* Compresses a sequence by detecting immediately repeating subsequences
* hierarchically. Optimal result but high performance complexity.
*
* @todo Link to observable demo
* @param {Array} sequence array with immediately repeating subsequences
* @returns {object} compressed hierarchy
*/
export function compress (sequence) {
if (!sequence || sequence.length === 0) {
return null
}
const longestReps = getImmediateRepetitions(sequence)
if (longestReps === null) {
return sequence
}
// Get repetition
const { seq, rep, length: len, pos } = longestReps[0]
// Get rest of sequence
const preSeq = sequence.slice(0, pos)
const postSeq = sequence.slice(pos + len * rep)
// Recurse with longest repetition
const repetition = compress(seq)
// Recurse with left rest
const pre = compress(preSeq)
// Recurse with right rest
const post = compress(postSeq)
// Get current depth
const depth = Math.max(
pre?.depth ?? 0,
(repetition?.depth ?? 0) + 1,
post?.depth ?? 0
)
// Get current length / width, i.e. compressed sequence length
const length =
(pre?.length ?? 0) +
(repetition?.length ?? 0) +
(post?.length ?? 0)
return {
pre,
seq: repetition,
rep,
post,
// Depth, leaves are 0, root is highest
depth,
// Compressed length
length,
// Include complete sequence of this node
content: sequence
}
}
/**
* Finds all immediate repetitions in a given sequence.
*
* @todo implement a mode that just looks for the best one, instead of later
* sorting all found ones (will be faster since less sequence.slice() happens)
* Still needs to keep all results with the same score
* @param {Array} sequence array with immediately repeating subsequences
* @returns {object[]} result
*/
export function getImmediateRepetitions (sequence = []) {
const foundReps = []
// For each length, look for a repetition that has that length
for (let length = Math.floor(sequence.length / 2); length > 0; --length) {
for (let pos = 0; pos < sequence.length - length; ++pos) {
let numberOfReps = 0
// eslint-disable-next-line no-constant-condition
while (true) {
// Let's see how often the slice at pos with the current length repeats immediately...
const startPos = pos + (numberOfReps + 1) * length
const found = arraySlicesEqual(
sequence,
sequence,
length,
pos,
startPos
)
if (!found) {
// No more repetitions found
break
} else {
// Continue with searching for more
numberOfReps++
}
// Did we find any repetitions?
if (numberOfReps > 0) {
const rep = numberOfReps + 1
const seq = sequence.slice(pos, pos + length)
foundReps.push({
length,
pos,
rep,
seq,
totalLength: length * rep
})
}
}
}
}
if (foundReps.length > 0) {
// Prioritize the ones that encompass most of the sequence, i.e., maximum length*rep
return foundReps.sort((a, b) => {
// If same total length, choose the one with more repetitions
// Better 8*a than 4*aa
return a.totalLength === b.totalLength
? b.rep - a.rep
: b.totalLength - a.totalLength
})
}
return null
}
/**
* Restores the original array/sequence from the compressed hierarchy.
*
* @param {object} tree compressed hierarchy
* @returns {Array} decompressed sequence
*/
export function decompress (tree) {
if (!tree) {
return []
}
if (tree.join) {
return tree
}
const seq = decompress(tree.seq)
const repetition = Array.from({ length: tree.rep }).map(() => seq)
return [
...decompress(tree.pre),
...repetition.flat(),
...decompress(tree.post)
]
}
/**
* Returns the summary of a hierachy, leaving out information about repetitions.
*
* @param {object} tree compressed hierarchy
* @returns {Array} summary
* @example
* const arr = '12312345656'.split('')
* const h = compress(arr)
* summary(h).join('')
* // '123456'
*/
export function summary (tree) {
if (!tree) {
return []
}
if (tree.join) {
return tree
}
return [
...summary(tree.pre),
...summary(tree.seq),
...summary(tree.post)
]
}
/**
* Formats a compressed hierarchy into a readable string, for example:
* "1222333222333" => "1 (2x (3x 2) (3x 3))"
*
* @param {object} tree compressed hierarchy
* @param {string} separator separator
* @returns {string} result
*/
export function toString (tree, separator = ' ') {
if (!tree) {
return ''
}
if (tree.join) {
return tree.join(separator)
}
const seq = toString(tree.seq)
const repetition = `(${tree.rep}x ${seq})`
return [
toString(tree.pre),
repetition,
toString(tree.post)
].join(separator).trim()
}
/**
* Calculates the compression rate in [0, 1] for a result of compress().
*
* @param {object} compressed compressed hierachy
* @returns {number} compression ratio
* @throws {'Invalid hierarchy'} for invalid hierarchy
*/
export function compressionRate (compressed) {
if (!compressed?.length || !compressed?.content?.length) {
throw new Error('Invalid hierarchy')
}
return compressed.length / compressed.content.length
}