Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/public/cantusdata/helpers/search_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,49 @@
queries.
"""

from typing import Optional

from cantusdata.helpers.neume_helpers import NEUME_NAMES

# Volpiano note characters and the pitch names they represent. Uppercase
# letters are liquescent forms of the same pitch; "(" and ")" are the
# liquescent forms of "8" and "9".
VOLPIANO_PITCH_NAMES: dict[str, str] = {
"8": "f",
"9": "g",
"a": "a",
"b": "b",
"c": "c",
"d": "d",
"e": "e",
"f": "f",
"g": "g",
"h": "a",
"j": "b",
"k": "c",
"l": "d",
"m": "e",
"n": "f",
"o": "g",
"p": "a",
"q": "b",
"r": "c",
"s": "d",
}
VOLPIANO_PITCH_NAMES.update(
{
char.upper(): pname
for char, pname in VOLPIANO_PITCH_NAMES.items()
if char.isalpha()
}
)
VOLPIANO_PITCH_NAMES.update({"(": "f", ")": "g"})

# Volpiano characters that are not notes: clefs, spacing, barlines, and
# flat/natural signs. OMR n-grams store pitch names without accidentals,
# so none of these can narrow a search of the MEI.
VOLPIANO_IGNORED_CHARS = set("12-34567 iwxyzIWXYZ")


def validate_intervals_query_word(word: str) -> bool:
"""
Expand Down Expand Up @@ -73,6 +114,28 @@ def get_transpositions(sequence: list[str]) -> list[list[str]]:
return transpositions


def volpiano_to_pitch_names(volpiano: str) -> Optional[list[str]]:
"""
Convert a Volpiano string into the sequence of pitch names it contains,
so that it can be searched against OMR n-grams.

Octave is dropped (OMR n-grams do not record it), and clefs, spacing,
barlines and accidentals are skipped (see VOLPIANO_IGNORED_CHARS).

e.g. volpiano_to_pitch_names("1---f--gh--3") returns ["f", "g", "a"]

:return: the pitch names, or None if the string contains a character
that is not Volpiano or contains no notes
"""
pitch_names: list[str] = []
for char in volpiano:
if char in VOLPIANO_PITCH_NAMES:
pitch_names.append(VOLPIANO_PITCH_NAMES[char])
elif char not in VOLPIANO_IGNORED_CHARS:
return None
return pitch_names or None


def translate_interval_query_direction(query_terms: list[str]) -> list[str]:
"""
Translate the terms of an interval query (alphanumeric strings; e.g. "u3", "d2", "r")
Expand Down
23 changes: 23 additions & 0 deletions app/public/cantusdata/test/core/helpers/test_search_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
validate_query,
get_transpositions,
translate_interval_query_direction,
volpiano_to_pitch_names,
)


Expand Down Expand Up @@ -67,3 +68,25 @@ def test_translate_interval_query_direction(self) -> None:
expected_translated_query = ["2", "-3", "1", "-14", "12"]
translated_query = translate_interval_query_direction(query_elems)
self.assertEqual(translated_query, expected_translated_query)

def test_volpiano_to_pitch_names(self) -> None:
with self.subTest("Clef, spacing and barlines are skipped"):
self.assertEqual(
volpiano_to_pitch_names("1---f--gh--3---k"), ["f", "g", "a", "c"]
)
with self.subTest("Octaves map to the same pitch names"):
self.assertEqual(
volpiano_to_pitch_names("9abcdefghjklmnopqrs"),
list("gabcdefgabcdefgabcd"),
)
self.assertEqual(volpiano_to_pitch_names("8"), ["f"])
with self.subTest("Liquescents map to their plain pitch"):
self.assertEqual(volpiano_to_pitch_names("fG()H"), list("fgfga"))
with self.subTest("Accidentals are skipped"):
self.assertEqual(volpiano_to_pitch_names("1-ij-Ij"), ["b", "b"])
with self.subTest("Invalid characters"):
self.assertIsNone(volpiano_to_pitch_names("1-f-t"))
self.assertIsNone(volpiano_to_pitch_names("f g?"))
with self.subTest("No notes"):
self.assertIsNone(volpiano_to_pitch_names("1---3"))
self.assertIsNone(volpiano_to_pitch_names(""))
19 changes: 19 additions & 0 deletions app/public/cantusdata/test/core/views/test_search_notation_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ def test_create_query_string(self) -> None:
)
expected_query_string = "intervals:2_-3_1_12"
self.assertEqual(query_string, expected_query_string)
# A volpiano query is converted to pitch names, keeping case so that
# liquescents (uppercase) are recognized.
with self.subTest("Test volpiano query"):
query_string = self.search_notation_view.create_query_string(
"1---f--G--h---3", "volpiano"
)
self.assertEqual(query_string, "pitch_names:f_g_a")
with self.subTest("Test invalid volpiano query"):
with self.assertRaises(NotationSearchException):
self.search_notation_view.create_query_string("1---3", "volpiano")

def test_do_query(self) -> None:
with self.subTest("Test fields returned"):
Expand Down Expand Up @@ -165,6 +175,15 @@ def test_get(self) -> None:
response_data = response.json()
self.assertIn("results", response_data)
self.assertIn("numFound", response_data)
with self.subTest("Test volpiano finds the same results as pitch"):
pitch_response = self.client.get(
url, {"q": "f g a", "type": "pitch_names", "manuscript_id": 123723}
).json()
volpiano_response = self.client.get(
url, {"q": "1---f--g--h", "type": "volpiano", "manuscript_id": 123723}
).json()
self.assertGreater(pitch_response["numFound"], 0)
self.assertEqual(volpiano_response, pitch_response)

def test_get_result_whose_document_has_no_image_uri(self) -> None:
"""
Expand Down
8 changes: 8 additions & 0 deletions app/public/cantusdata/views/search_notation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
validate_query,
get_transpositions,
translate_interval_query_direction,
volpiano_to_pitch_names,
)

RETURNED_FIELDS = [
Expand Down Expand Up @@ -84,6 +85,13 @@ def create_query_string(self, q: str, q_type: str) -> str:
"""
Format the query and query type into a string for solr.
"""
if q_type == "volpiano":
# Volpiano is case-sensitive (uppercase marks liquescents) and is
# not space-separated, so it skips the normalization below.
pitch_names = volpiano_to_pitch_names(q)
if pitch_names is None:
raise NotationSearchException("Invalid query.")
return f"pitch_names:{'_'.join(pitch_names)}"
normalized_q_elems = q.lower().split()
q_valid = validate_query(normalized_q_elems, q_type)
if not q_valid:
Expand Down
12 changes: 10 additions & 2 deletions nginx/app/src/js/manuscript-detail/ManuscriptDetailPageView.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,22 @@ export default Marionette.View.extend({
});

// Initialize the search view
// Volpiano normally searches chant records from Cantus Database. A
// manuscript without them can still be searched by Volpiano through
// its MEI, which the notation search provider then handles instead.
var volpianoFromMei = !this.model.get('chants_loaded') &&
this.model.isPluginActivated('pitch-search');

var chantSearchProvider = new ChantSearchProvider({
additionalResultFields: ['genre', 'mode', 'feast', 'office', 'position']
additionalResultFields: ['genre', 'mode', 'feast', 'office', 'position'],
excludedFields: volpianoFromMei ? ['volpiano', 'volpiano_literal'] : []
});

chantSearchProvider.setRestriction('manuscript_id', '"' + this.model.get("id") + '"');

var notationSearchProvider = new OMRSearchProvider({
manuscript: this.model
manuscript: this.model,
volpianoSearch: volpianoFromMei
});

var searchTerm = manuscriptStateChannel.request('search');
Expand Down
6 changes: 6 additions & 0 deletions nginx/app/src/js/search/chant-search/ChantSearchProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,19 @@ export default Marionette.Object.extend({
* search type is Volpiano) the chant's Volpiano
* - `restriction`: object with field: value mappings of restrictions
* to apply to all queries made by the controller
* - `excludedFields`: types of search fields not to offer
*/
initialize: function () {
_.bindAll(this, 'search', 'setRestriction', 'getSearchMetadata');

// Set options
this.restrictions = this.getOption('restrictions') || {};

var excludedFields = this.getOption('excludedFields') || [];
this.fields = _.reject(this.fields, function (field) {
return _.contains(excludedFields, field.type);
});

// Initialize search input model
this.searchParameters = new SearchInput();

Expand Down
12 changes: 12 additions & 0 deletions nginx/app/src/js/search/omr-search/OMRSearchProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SearchResultHeadingView from "../SearchResultHeadingView";
import NeumeGalleryView from "./NeumeGalleryView";
import ContourChoiceView from "./ContourChoiceView";
import IntervalChoiceView from "./IntervalChoiceView";
import VolpianoHelpView from "./VolpianoHelpView";
import InputView from "./InputView";
import ResultView from "./ResultView";

Expand All @@ -21,6 +22,10 @@ var manuscriptChannel = Radio.channel('manuscript');
*
* - `manuscript`: the manuscript model to initialize with
*
* Optional initialization parameters:
*
* - `volpianoSearch`: offer a Volpiano field that searches the MEI by pitch
*
* See SearchView for a further description of the contract this class fulfills.
*/
export default Marionette.Object.extend({
Expand Down Expand Up @@ -82,6 +87,9 @@ export default Marionette.Object.extend({
this.fields.push.apply(this.fields, plugin.fields);
}, this);

if (options.volpianoSearch)
this.fields.push({ name: 'Volpiano', type: 'volpiano' });

this.results = new SearchNotationResultCollection();
this.displayedResults = new SearchNotationResultCollection();
this.resultLoadingHandler = new IncrementalClientSideLoader(this.displayedResults, this.results);
Expand Down Expand Up @@ -220,6 +228,10 @@ export default Marionette.Object.extend({
});
regions.searchHelper.show(intervalChoices);
}
else if (field.type === 'volpiano')
{
regions.searchHelper.show(new VolpianoHelpView());
}
else
{
regions.searchHelper.empty();
Expand Down
8 changes: 8 additions & 0 deletions nginx/app/src/js/search/omr-search/VolpianoHelpView.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Marionette from 'marionette';

import template from './volpiano-help.template.html';

/** Explain how a Volpiano query is matched against the MEI */
export default Marionette.View.extend({
template
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<p class="help-block">
This manuscript has no chant records, so Volpiano is searched in its encoded notation.
Notes are matched by pitch name only: octave, accidentals, clefs and barlines are ignored.
Melodies of up to 5 notes are always found; longer ones are found only where they span
at most 5 whole neumes.
</p>
Loading