diff --git a/README.md b/README.md
index 4696595..d0e9501 100644
--- a/README.md
+++ b/README.md
@@ -55,12 +55,95 @@ Launch the web application:
In debug mode:
```
FLASK_APP=shabda FLASK_DEBUG=1 flask run
+
+or
+
+FLASK_APP=shabda FLASK_DEBUG=1 poetry run flask run
+
```
In production:
```
gunicorn --workers=4 "shabda:create_app()" -b localhost:8000
```
+Then navigate to `http://localhost:5000/` (or your production URL).
+
+### Pack Tab
+
+Fetch random audio samples from Freesound based on words.
+
+- **Pack definition**: Enter one or more words separated by commas (e.g., `blue,red:3`).
+- **Licenses**: Select which Creative Commons licenses to allow (cc0, by, by-nc).
+- **Fetch pack**: Generates a reslist with sample URLs.
+
+Use in Estuary:
+```
+!reslist "http://your-shabda-url/blue,red.json"
+```
+
+Use in Strudel:
+```
+samples('shabda:blue:2,red:3')
+```
+
+### Speech Tab
+
+Generate Text-to-Speech samples from text input.
+
+- **Speech definition**: Enter words or sentences (spaces become underscores; `hello_world` produces a "hello world" sample).
+- **Gender**: Choose Female or Male voice.
+- **Language**: Select from 50+ languages (e.g., en-GB, en-US, fr-FR, ja-JP).
+- **Fetch speech**: Generates audio samples using the selected voice.
+
+Use in Estuary:
+```
+!reslist "http://your-shabda-url/speech/hello_world.json?language=en-GB&gender=f"
+```
+
+Use in Strudel:
+```
+samples('shabda/speech/en-GB/f:hello_world')
+```
+
+### Phonemes Tab
+
+Generate phoneme-level samples with musical timing alignment. Each phoneme chunk becomes a sample, with stress-aware line wrapping for Strudel integration.
+
+**Inputs:**
+- **Phoneme definition**: Enter lyrics (one or more sentences, any case). Commas and underscores separate phrases. Example:
+ ```
+ Papa was a rolling stone
+ Wherever he laid his hat
+ ```
+- **ARPABET overrides**: Specify custom pronunciations for words in `word:PH1_PH2 format` (e.g., `papa:P_AA1_P_A;hat:HH_AE1_T`). Separate multiple overrides with semicolons.
+- **Beats / bar**: Musical meter (default 4 for 4/4 time).
+- **Bars / line**: Lines per phrase (default 2).
+- **Target stress beat**: Which beat in the bar should primary stressed syllables land on (default 3 for a pickup feel; use 1 for downbeat emphasis).
+- **Gender**: Choose Female or Male voice (default: Male).
+- **Language**: Select pronunciation language (default: en-GB).
+
+**Actions:**
+- **Preview phonemes**: Returns text analysis and Strudel timing without synthesizing audio (fast).
+- **Fetch phonemes**: Synthesizes all phoneme samples as WAV files (slower first time, then cached).
+
+**Output includes:**
+- IPA transcription of each word
+- ARPABET phoneme breakdown
+- Stress pattern analysis
+- Timed Strudel phrases with beat alignment
+- Direct audio sample URLs
+
+Use in Strudel:
+```
+samples('shabda/phonemes/en-GB/m:papa_was_a_rolling_stone', { overrides: 'papa:P_AA1_P_A' })
+s("phc_P_AA1_P_A phc_W_AA1_Z phc_AH0 ~ phc_R_OW1_L_IH0_NG ...")
+```
+
+**How stress alignment works:**
+- If a line's first phoneme chunk is stressed (marked with 1 or 2), it lands on beat 1 (no rest needed).
+- If a line starts unstressed, rests are added so the first stressed chunk lands on the target beat (default beat 3).
+- This ensures consistent musical phrasing across all lines.
+
Test
----
diff --git a/assets/shabda.css b/assets/shabda.css
index 3446214..ff9a674 100644
--- a/assets/shabda.css
+++ b/assets/shabda.css
@@ -35,7 +35,8 @@ body {
}
#definition,
-#speechdefinition {
+#speechdefinition,
+#phonemedefinition {
border: 2px dotted #110072;
border-radius: 5px;
padding: 10px;
@@ -44,8 +45,15 @@ body {
color: #110072;
}
+#phonemedefinition {
+ min-height: 4.5em;
+ width: min(42rem, calc(100% - 20px));
+ resize: vertical;
+}
+
#definition:focus,
-#speechdefinition:focus {
+#speechdefinition:focus,
+#phonemedefinition:focus {
outline: none;
border: 2px solid #110072;
}
@@ -69,6 +77,25 @@ body {
margin-left: 10px;
}
+#timing {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+ margin: 10px 0;
+}
+
+#timing label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.85em;
+}
+
+#timing input[type=number] {
+ width: 5em;
+}
+
#tabs span {
display: inline-block;
padding: 10px;
@@ -98,7 +125,6 @@ button,
margin: 10px;
color: #110072;
cursor: pointer;
- background-color: white;
text-decoration-line: none;
font-size: 14px;
}
@@ -114,6 +140,31 @@ button:active,
box-shadow: 1px 1px 2px #110072;
}
+#overrides {
+ margin: 8px 10px;
+}
+
+#overrides label {
+ display: block;
+ font-size: 0.85em;
+ margin-bottom: 4px;
+}
+
+#phoneme-overrides {
+ width: min(42rem, calc(100% - 20px));
+ border: 2px dotted #110072;
+ border-radius: 5px;
+ padding: 10px;
+ margin: 10px;
+ background-color: transparent;
+ color: #110072;
+}
+
+#phoneme-overrides:focus {
+ outline: none;
+ border: 2px solid #110072;
+}
+
button img,
.button img {
vertical-align: top;
@@ -135,6 +186,59 @@ button img,
cursor: pointer;
}
+.phoneme-results {
+ display: block;
+}
+
+.phoneme-sentences {
+ margin-top: 16px;
+}
+
+.phoneme-sentence {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.phoneme-word {
+ min-width: 10em;
+ padding: 10px 12px;
+ border: 1px solid #110072;
+ border-radius: 8px;
+ background-color: rgba(255, 255, 255, 0.5);
+}
+
+.phoneme-word strong {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 1rem;
+}
+
+.phoneme-ipa,
+.phoneme-arpabet,
+.phoneme-stress {
+ display: block;
+ font-family: monospace;
+ font-size: 0.85em;
+ line-height: 1.3;
+}
+
+.phoneme-strudel,
+.phoneme-audio {
+ margin-top: 18px;
+}
+
+.phoneme-bank {
+ margin-bottom: 12px;
+}
+
+.phoneme-bank h4,
+.phoneme-strudel h3,
+.phoneme-audio h3 {
+ margin-bottom: 6px;
+}
+
.copied {
margin-left: 5px;
opacity: 0;
diff --git a/assets/shabda.js b/assets/shabda.js
index f66bdb6..f5e361b 100644
--- a/assets/shabda.js
+++ b/assets/shabda.js
@@ -1,21 +1,171 @@
var root = document.body
+var LANGUAGE_OPTIONS = [
+ ["af-ZA", "Afrikaans"],
+ ["ar-XA", "Arabic"],
+ ["bn-IN", "Bengali"],
+ ["bg-BG", "Bulgarian"],
+ ["ca-ES", "Catalan"],
+ ["yue-HK", "Chinese (Hong Kong)"],
+ ["cs-CZ", "Czech"],
+ ["da-DK", "Danish"],
+ ["nl-BE", "Dutch (Belgium)"],
+ ["nl-NL", "Dutch (Netherlands)"],
+ ["en-AU", "English (Australia)"],
+ ["en-IN", "English (India)"],
+ ["en-GB", "English (UK)"],
+ ["en-US", "English (US)"],
+ ["fil-PH", "Filipino"],
+ ["fi-FI", "Finnish"],
+ ["fr-CA", "French (Canada)"],
+ ["fr-FR", "French (France)"],
+ ["de-DE", "German"],
+ ["el-GR", "Greek"],
+ ["gu-IN", "Gujarati"],
+ ["hi-IN", "Hindi"],
+ ["hu-HU", "Hungarian"],
+ ["is-IS", "Icelandic"],
+ ["id-ID", "Indonesian"],
+ ["it-IT", "Italian"],
+ ["ja-JP", "Japanese"],
+ ["kn-IN", "Kannada"],
+ ["ko-KR", "Korean"],
+ ["lv-LV", "Latvian"],
+ ["ms-MY", "Malay"],
+ ["ml-IN", "Malayalam"],
+ ["cmn-CN", "Mandarin Chinese"],
+ ["mr-IN", "Marathi"],
+ ["nb-NO", "Norwegian"],
+ ["pl-PL", "Polish"],
+ ["pt-BR", "Portuguese (Brazil)"],
+ ["pt-PT", "Portuguese (Portugal)"],
+ ["pa-IN", "Punjabi"],
+ ["ro-RO", "Romanian"],
+ ["ru-RU", "Russian"],
+ ["sr-RS", "Serbian"],
+ ["sk-SK", "Slovak"],
+ ["es-ES", "Spanish (Spain)"],
+ ["es-US", "Spanish (US)"],
+ ["sv-SE", "Swedish"],
+ ["ta-IN", "Tamil"],
+ ["te-IN", "Telugu"],
+ ["th-TH", "Thai"],
+ ["tr-TR", "Turkish"],
+ ["uk-UA", "Ukrainian"],
+ ["vi-VN", "Vietnamese"],
+]
+
+function renderLanguageSelect(id, value, onChange) {
+ return m("select#" + id, { onchange: onChange, value: value },
+ LANGUAGE_OPTIONS.map(function (option) {
+ return m("option[value=" + option[0] + "]", option[1])
+ }))
+}
+
+function renderCopyControls(type) {
+ return [
+ m("small", "Insert in Estuary terminal: "),
+ m("pre", State.lastreslist(false, type)),
+ m("img", { onclick: function () { State.copyreslist(false, type) }, src: "assets/clipboard.png", height: "16" }),
+ State.copied ? m("span.copied", "copied") : null,
+ m("br"),
+ m("br"),
+ m("small", "Insert in Strudel: "),
+ m("pre", State.lastreslist(true, type)),
+ m("img", { onclick: function () { State.copyreslist(true, type) }, src: "assets/clipboard.png", height: "16" }),
+ State.strudelcopied ? m("span.copied", "copied") : null,
+ ]
+}
+
+function renderPhonemeSentence(sentence) {
+ return m("div.phoneme-sentence", sentence.map(function (word) {
+ return m("div.phoneme-word", [
+ m("strong", word.word),
+ word.ipa ? m("span.phoneme-ipa", word.ipa) : null,
+ word.arpabet ? m("span.phoneme-arpabet", word.arpabet) : null,
+ word.stress_pattern ? m("span.phoneme-stress", word.stress_pattern) : null,
+ ])
+ }))
+}
+
+function renderPhonemeBank(bank, urls) {
+ return m("div.phoneme-bank", [
+ m("h4", bank),
+ m("ul.samples", urls.map(function (url) {
+ return m("li", m("audio", { controls: true, src: url }))
+ }))
+ ])
+}
+
+function flattenStringLists(nestedLists) {
+ var flat = []
+ ;(nestedLists || []).forEach(function (list) {
+ ;(list || []).forEach(function (item) {
+ flat.push(String(item))
+ })
+ })
+ return flat
+}
+
+function toQuotedStringList(lines) {
+ if (!lines || lines.length === 0) {
+ return "[]"
+ }
+ return "[\n" + lines.map(function (line) {
+ return " \"" + String(line).replace(/\\/g, "\\\\").replace(/\"/g, "\\\"") + "\","
+ }).join("\n") + "\n]"
+}
+
+function normalizePhonemeDefinition(definition) {
+ return definition.trim()
+ .replace(/\r?\n+/g, ",")
+ .replace(/\s*,\s*/g, ",")
+ .replace(/[ \t]+/g, "_")
+ .toLowerCase()
+ .replace(/[^a-z0-9_,]+/g, "")
+ .replace(/_+/g, "_")
+ .replace(/,+/g, ",")
+ .replace(/^,|,$/g, "")
+}
+
var State = {
pack: "",
- progress: false,
- lastretrievedtype: "",
- lastretrieved: "",
- lastreslistcontents: [],
- error: "",
+ results: {
+ pack: { progress: false, retrieved: "", contents: [], error: "" },
+ speech: { progress: false, retrieved: "", contents: [], error: "" },
+ phonemes: { progress: false, retrieved: "", contents: {}, error: "" },
+ },
licenses: ["by", "cc0", "by-nc"],
speech: "",
- language: "uk-UA",
- gender: "f",
+ speechLanguage: "uk-UA",
+ speechGender: "f",
+ phonemes: "",//Papa was a rolling stone\n(Wherever he laid his hat)\n(was his home)\n(And when he died)\n(All he left us was alone)\nSay Papa\nBuild Papa up\nScream Papa",
+ phonemeLanguage: "en-GB",
+ phonemeGender: "m",
+ phonemeOverrides: "", //"papa:P_AA1_P_A",
+ beatsPerBar: 4,
+ barsPerLine: 2,
+ targetStressBeat: 3,
tab: "pack",
+ phonemePreview: true,
freesoundAvailable: true,
freesoundError: null,
freesoundErrorReason: null,
+ getResult: function (type) {
+ return State.results[type]
+ },
+
+ currentType: function () {
+ if (State.tab == "speech") {
+ return "speech"
+ }
+ if (State.tab == "phonemes") {
+ return "phonemes"
+ }
+ return "pack"
+ },
+
pollStatus: function () {
m.request({ method: "GET", url: "/status" })
.then(function (result) {
@@ -30,9 +180,9 @@ var State = {
licensefullname: function (name) {
switch (name) {
- case 'cc0': return "Public domain"
- case 'by': return "Attribution"
- case 'by-nc': return "Attribution non-commercial"
+ case "cc0": return "Public domain"
+ case "by": return "Attribution"
+ case "by-nc": return "Attribution non-commercial"
}
},
@@ -41,90 +191,124 @@ var State = {
},
setlicense: function (e) {
- name = e.target.value
+ var name = e.target.value
if (State.licenses.includes(name)) {
- State.licenses = State.licenses.filter(function (value, index, arr) {
- return value != name;
- });
+ State.licenses = State.licenses.filter(function (value) {
+ return value != name
+ })
}
else {
State.licenses.push(name)
}
},
- lastretrievedreslist: function () {
- return this.lastretrieved + ".json"
+ lastretrievedreslist: function (type) {
+ var result = State.getResult(type)
+ return result.retrieved + ".json"
},
- lastretrievedzip: function () {
- return window.location + this.lastretrieved + ".zip"
+ lastretrievedzip: function (type) {
+ var result = State.getResult(type)
+ return window.location + result.retrieved + ".zip"
+ },
+
+ phonemezipurl: function () {
+ var result = State.getResult("phonemes")
+ if (!result.retrieved) {
+ return ""
+ }
+ var url = new URL(State.lastretrievedzip("phonemes"))
+ url.searchParams.append("gender", State.phonemeGender)
+ url.searchParams.append("language", State.phonemeLanguage)
+ if (State.phonemeOverrides.trim()) {
+ url.searchParams.append("overrides", State.phonemeOverrides.trim())
+ }
+ return url.href
},
- lastreslist: function (strudel = false) {
- url = new URL(location.href + State.lastretrievedreslist())
- if (this.lastretrievedtype == "pack" && State.licenses.length < 3) {
- url.searchParams.append("licenses", State.licenses.join());
+ lastreslist: function (strudel = false, type = State.currentType()) {
+ var result = State.getResult(type)
+ if (!result.retrieved) {
+ return ""
}
- if (this.lastretrievedtype == "speech") {
- url.searchParams.append("gender", State.gender)
- url.searchParams.append("language", State.language)
+ var url = new URL(location.href + State.lastretrievedreslist(type))
+ if (type == "pack" && State.licenses.length < 3) {
+ url.searchParams.append("licenses", State.licenses.join())
+ }
+ if (type == "speech") {
+ url.searchParams.append("gender", State.speechGender)
+ url.searchParams.append("language", State.speechLanguage)
+ }
+ if (type == "phonemes") {
+ url.searchParams.append("gender", State.phonemeGender)
+ url.searchParams.append("language", State.phonemeLanguage)
+ if (State.phonemeOverrides.trim()) {
+ url.searchParams.append("overrides", State.phonemeOverrides.trim())
+ }
+ url.searchParams.append("beats_per_bar", State.beatsPerBar)
+ url.searchParams.append("bars_per_line", State.barsPerLine)
+ url.searchParams.append("target_stress_beat", State.targetStressBeat)
}
if (strudel) {
url.searchParams.append("strudel", 1)
- reslist = "samples('" + url.href + "')"
- }
- else {
- reslist = '!reslist "' + url.href + '"'
+ return "samples('" + url.href + "')"
}
-
- return reslist
+ return '!reslist "' + url.href + '"'
},
retrieve: function () {
- if (State.pack) {
- State.error = ""
- State.progress = true
- m.request({
- method: "GET",
- url: "/pack/" + encodeURIComponent(State.pack) + '?licenses=' + State.licenses.join()
- })
- .then(function (result) {
- State.progress = false
- if (result.status == "ok") {
- State.lastretrieved = result.definition
- State.lastretrievedtype = "pack"
- m.request({
- method: "GET",
- url: encodeURIComponent(State.lastretrievedreslist()) + '?complete=1&licenses=' + State.licenses.join()
- }).then(function (result) {
- State.lastreslistcontents = result
- })
- }
- else if (result.status == "empty") {
- State.error = "Pack is empty"
- }
- else {
- State.error = "An error occured"
- }
- }).catch(function (error) {
- State.progress = false
- if (error.code === 503 && error.response && error.response.status === "freesound_unavailable") {
- State.error = "Freesound is currently unavailable. Try again later."
- State.freesoundAvailable = false
- State.freesoundError = error.response.error
- } else {
- State.error = "An error occured"
- }
- })
- }
- else {
- State.error = "Please enter a pack definition"
+ var result = State.getResult("pack")
+ if (!State.pack) {
+ result.error = "Please enter a pack definition"
+ return
}
+
+ result.error = ""
+ result.progress = true
+ m.request({
+ method: "GET",
+ url: "/pack/" + encodeURIComponent(State.pack) + "?licenses=" + State.licenses.join(),
+ })
+ .then(function (result) {
+ var packResult = State.getResult("pack")
+ packResult.progress = false
+ if (result.status == "ok") {
+ packResult.retrieved = result.definition
+ m.request({
+ method: "GET",
+ url: encodeURIComponent(State.lastretrievedreslist("pack")) + "?complete=1&licenses=" + State.licenses.join(),
+ }).then(function (result) {
+ packResult.contents = result
+ })
+ }
+ else if (result.status == "empty") {
+ packResult.error = "Pack is empty"
+ }
+ else {
+ packResult.error = "An error occured"
+ }
+ })
+ .catch(function (error) {
+ var packResult = State.getResult("pack")
+ packResult.progress = false
+ if (error.code === 503 && error.response && error.response.status === "freesound_unavailable") {
+ packResult.error = "Freesound is currently unavailable. Try again later."
+ State.freesoundAvailable = false
+ State.freesoundError = error.response.error
+ }
+ else {
+ packResult.error = "An error occured"
+ }
+ })
},
- copyreslist: function (strudel = false) {
+ copyreslist: function (strudel = false, type = State.currentType()) {
+ var value = State.lastreslist(strudel, type)
+ if (!value) {
+ return
+ }
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
- if (navigator.clipboard.writeText(State.lastreslist(strudel))) {
+ if (navigator.clipboard.writeText(value)) {
if (strudel) {
State.strudelcopied = true
}
@@ -145,47 +329,113 @@ var State = {
m.redraw()
}, 2000)
}
-
},
retrievespeech: function () {
- if (State.speech) {
- State.error = ""
- State.progress = true
- m.request({
- method: "GET",
- url: "/speech/" + encodeURIComponent(State.speech) + '?language=' + State.language + '&gender=' + State.gender
+ var result = State.getResult("speech")
+ if (!State.speech) {
+ result.error = "Please enter a pack definition"
+ return
+ }
+
+ result.error = ""
+ result.progress = true
+ m.request({
+ method: "GET",
+ url: "/speech/" + encodeURIComponent(State.speech) + "?language=" + State.speechLanguage + "&gender=" + State.speechGender,
+ })
+ .then(function (result) {
+ var speechResult = State.getResult("speech")
+ speechResult.progress = false
+ if (result.status == "ok") {
+ speechResult.retrieved = "speech/" + result.definition
+ m.request({
+ method: "GET",
+ url: encodeURIComponent(State.lastretrievedreslist("speech")) + "?language=" + State.speechLanguage + "&gender=" + State.speechGender,
+ }).then(function (result) {
+ speechResult.contents = result
+ })
+ }
+ else if (result.status == "empty") {
+ speechResult.error = "Pack is empty"
+ }
+ else {
+ speechResult.error = "An error occured"
+ }
})
- .then(function (result) {
- State.progress = false
- if (result.status == "ok") {
- State.lastretrieved = "speech/" + result.definition
- State.lastretrievedtype = "speech"
- m.request({
- method: "GET",
- url: encodeURIComponent(State.lastretrievedreslist()) + '?language=' + State.language + '&gender=' + State.gender
- }).then(function (result) {
- State.lastreslistcontents = result
- })
- }
- else if (result.status == "empty") {
- State.error = "Pack is empty"
- }
- else {
- State.error = "An error occured"
- }
- }).catch(function (error) {
- State.error = "An error occured"
- })
+ .catch(function () {
+ var speechResult = State.getResult("speech")
+ speechResult.progress = false
+ speechResult.error = "An error occured"
+ })
+ },
+
+ retrievephonemes: function (preview = true) {
+ var result = State.getResult("phonemes")
+ if (!State.phonemes) {
+ result.error = "Please enter a phoneme definition"
+ return
}
- else {
- State.error = "Please enter a pack definition"
+
+ result.error = ""
+ result.progress = true
+ State.phonemePreview = preview
+ var phonemeDefinition = normalizePhonemeDefinition(State.phonemes)
+ var phonemeOverrides = State.phonemeOverrides.trim()
+ var query =
+ "?language=" + State.phonemeLanguage +
+ "&gender=" + State.phonemeGender +
+ "&beats_per_bar=" + State.beatsPerBar +
+ "&bars_per_line=" + State.barsPerLine +
+ "&target_stress_beat=" + State.targetStressBeat +
+ "&strudel=1&details=1&preview=" + (preview ? 1 : 0)
+ if (phonemeOverrides) {
+ query += "&overrides=" + encodeURIComponent(phonemeOverrides)
}
+ m.request({
+ method: "GET",
+ url: "/phonemes/" + encodeURIComponent(phonemeDefinition) + ".json" + query,
+ })
+ .then(function (result) {
+ var phonemeResult = State.getResult("phonemes")
+ phonemeResult.progress = false
+ if (result._base) {
+ phonemeResult.retrieved = "phonemes/" + phonemeDefinition
+ phonemeResult.contents = result
+ }
+ else {
+ phonemeResult.error = "An error occured"
+ }
+ })
+ .catch(function () {
+ var phonemeResult = State.getResult("phonemes")
+ phonemeResult.progress = false
+ phonemeResult.error = "An error occured"
+ })
},
}
var Shabda = {
view: function () {
+ var activeType = State.currentType()
+ var activeResult = State.getResult(activeType)
+ var activeContents = activeResult.contents || []
+ var phonemeBanks = Object.keys(activeType == "phonemes" ? activeContents : {}).filter(function (key) {
+ if (key.charAt(0) == "_") {
+ return false
+ }
+ if (
+ key == "sentences_strudel" ||
+ key == "sentences_strudel_timed" ||
+ key == "sentences_phonetic" ||
+ key == "beats_per_bar" ||
+ key == "bars_per_line"
+ ) {
+ return false
+ }
+ return Array.isArray(activeContents[key])
+ })
+
return m("main", [
!State.freesoundAvailable ? m("div#freesound-banner",
State.freesoundErrorReason === "auth"
@@ -201,7 +451,8 @@ var Shabda = {
m("h1", [m("img", { src: "assets/logo.svg", height: "40", title: 'Shabda is the Sanskrit word for "speech sound"' }), "Shabda"]),
- m("p.intro", ["Shabda is a tool for assembling and sharing packs of found audio samples.",
+ m("p.intro", [
+ "Shabda is a tool for assembling and sharing packs of found audio samples.",
m("br"),
"It fetches samples from ",
m("a", { href: "https://freesound.org/" }, "freesound.org"),
@@ -211,25 +462,25 @@ var Shabda = {
m("a", { href: "https://estuary.mcmaster.ca/" }, "Estuary"),
" and ",
m("a", { href: "https://strudel.tidalcycles.org/" }, "Strudel"),
- "."]),
+ ".",
+ ]),
- m("div",
+ m("div", [
m("div#tabs", [
- m("span#packs_tab",
- {
- class: State.tab == "pack" ? "selected" : "",
- onclick: function () { State.tab = "pack" }
- },
- "Pack"
- ),
- m("span#speech_tab",
- {
- class: State.tab == "speech" ? "selected" : "",
- onclick: function () { State.tab = "speech" }
- },
- "Speech"
- ),
+ m("span#packs_tab", {
+ class: State.tab == "pack" ? "selected" : "",
+ onclick: function () { State.tab = "pack" },
+ }, "Pack"),
+ m("span#speech_tab", {
+ class: State.tab == "speech" ? "selected" : "",
+ onclick: function () { State.tab = "speech" },
+ }, "Speech"),
+ m("span#phonemes_tab", {
+ class: State.tab == "phonemes" ? "selected" : "",
+ onclick: function () { State.tab = "phonemes" },
+ }, "Phonemes"),
]),
+
m("div.tabcontent", { style: State.tab == "pack" ? "display:block;" : "" }, [
m("input[placeholder=Pack definition]#definition", {
value: State.pack,
@@ -238,7 +489,7 @@ var Shabda = {
if (e.keyCode == 13) {
State.retrieve()
}
- }
+ },
}),
m("div#licenses", [
m("input[type=checkbox]#license-cc0", { name: "licenses", value: "cc0", checked: State.haslicense("cc0"), oninput: State.setlicense }),
@@ -250,16 +501,11 @@ var Shabda = {
m("input[type=checkbox]#license-by-nc", { name: "licenses", value: "by-nc", checked: State.haslicense("by-nc"), oninput: State.setlicense }),
m("label[for=license-by-nc]", State.licensefullname("by-nc")),
]),
- m("button", {
- onclick: State.retrieve
- }, "Fetch pack"),
-
+ m("button", { onclick: State.retrieve }, "Fetch pack"),
m("br"),
m("br"),
-
m("div.help", [
m("img", { src: "assets/help.png", height: "32", title: "Help" }),
-
m("p.explanation", [
"Any word can be a pack definition.",
m("br"),
@@ -274,9 +520,9 @@ var Shabda = {
"In a hurry? You can directly include a pack in Estuary by executing in its terminal:",
m("pre", '!reslist "' + location.href + 'blue,red.json"'),
"Or in Strudel:",
- m("pre", "samples('shabda:blue:2,red:3')")
+ m("pre", "samples('shabda:blue:2,red:3')"),
]),
- ])
+ ]),
]),
m("div.clear"),
@@ -289,80 +535,20 @@ var Shabda = {
if (e.keyCode == 13) {
State.retrievespeech()
}
- }
+ },
}),
m("div#genders", [
- m("select#gender", { onchange: function (e) { State.gender = this.value } }, [
- m("option[value=f]", { selected: State.gender == "f" }, "Female"),
- m("option[value=m]", { selected: State.gender == "m" }, "Male")
+ m("select#gender", { onchange: function () { State.speechGender = this.value } }, [
+ m("option[value=f]", { selected: State.speechGender == "f" }, "Female"),
+ m("option[value=m]", { selected: State.speechGender == "m" }, "Male"),
]),
]),
- m("div#languages", [
- m('select#language', { onchange: function (e) { State.language = this.value }, value: State.language }, [
- m("option[value=af-ZA]", "Afrikaans"),
- m("option[value=ar-XA]", "Arabic"),
- m("option[value=bn-IN]", "Bengali"),
- m("option[value=bg-BG]", "Bulgarian"),
- m("option[value=ca-ES]", "Catalan"),
- m("option[value=yue-HK]", "Chinese (Hong Kong)"),
- m("option[value=cs-CZ]", "Czech"),
- m("option[value=da-DK]", "Danish"),
- m("option[value=nl-BE]", "Dutch (Belgium)"),
- m("option[value=nl-NL]", "Dutch (Netherlands)"),
- m("option[value=en-AU]", "English (Australia)"),
- m("option[value=en-IN]", "English (India)"),
- m("option[value=en-GB]", "English (UK)"),
- m("option[value=en-US]", "English (US)"),
- m("option[value=fil-PH]", "Filipino"),
- m("option[value=fi-FI]", "Finnish"),
- m("option[value=fr-CA]", "French (Canada)"),
- m("option[value=fr-FR]", "French (France)"),
- m("option[value=de-DE]", "German"),
- m("option[value=el-GR]", "Greek"),
- m("option[value=gu-IN]", "Gujarati"),
- m("option[value=hi-IN]", "Hindi"),
- m("option[value=hu-HU]", "Hungarian"),
- m("option[value=is-IS]", "Icelandic"),
- m("option[value=id-ID]", "Indonesian"),
- m("option[value=it-IT]", "Italian"),
- m("option[value=ja-JP]", "Japanese"),
- m("option[value=kn-IN]", "Kannada"),
- m("option[value=ko-KR]", "Korean"),
- m("option[value=lv-LV]", "Latvian"),
- m("option[value=ms-MY]", "Malay"),
- m("option[value=ml-IN]", "Malayalam"),
- m("option[value=cmn-CN]", "Mandarin Chinese"),
- m("option[value=mr-IN]", "Marathi"),
- m("option[value=nb-NO]", "Norwegian"),
- m("option[value=pl-PL]", "Polish"),
- m("option[value=pt-BR]", "Portuguese (Brazil)"),
- m("option[value=pt-PT]", "Portuguese (Portugal)"),
- m("option[value=pa-IN]", "Punjabi"),
- m("option[value=ro-RO]", "Romanian"),
- m("option[value=ru-RU]", "Russian"),
- m("option[value=sr-RS]", "Serbian"),
- m("option[value=sk-SK]", "Slovak"),
- m("option[value=es-ES]", "Spanish (Spain)"),
- m("option[value=es-US]", "Spanish (US)"),
- m("option[value=sv-SE]", "Swedish"),
- m("option[value=ta-IN]", "Tamil"),
- m("option[value=te-IN]", "Telugu"),
- m("option[value=th-TH]", "Thai"),
- m("option[value=tr-TR]", "Turkish"),
- m("option[value=uk-UA]", "Ukrainian"),
- m("option[value=vi-VN]", "Vietnamese")
- ])
- ]),
- m("button", {
- onclick: State.retrievespeech
- }, "Fetch speech"),
-
+ m("div#languages", [renderLanguageSelect("language", State.speechLanguage, function () { State.speechLanguage = this.value })]),
+ m("button", { onclick: State.retrievespeech }, "Fetch speech"),
m("br"),
m("br"),
-
m("div.help", [
m("img", { src: "assets/help.png", height: "32", title: "Help" }),
-
m("p.explanation", [
"Any word can be a speech definition.",
m("br"),
@@ -377,63 +563,135 @@ var Shabda = {
"In a hurry? You can directly include speech in Estuary by executing in its terminal:",
m("pre", '!reslist "' + location.href + 'speech/blue,red.json?gender=f&language=en-GB"'),
"Or in Strudel:",
- m("pre", "samples('shabda/speech/en-GB/f:blue,red')")
+ m("pre", "samples('shabda/speech/en-GB/f:blue,red')"),
]),
- ])
+ ]),
+ ]),
+
+ m("div.tabcontent", { style: State.tab == "phonemes" ? "display:block;" : "" }, [
+ m("textarea[placeholder=Phoneme definition]#phonemedefinition", {
+ value: State.phonemes,
+ oninput: function (e) { State.phonemes = e.target.value },
+ onkeyup: function (e) {
+ if (e.keyCode == 13) {
+ State.retrievephonemes()
+ }
+ },
+ }),
+ m("div#timing", [
+ m("label", ["Beats / bar ", m("input[type=number]#beatsperbar", {
+ min: 1,
+ step: 1,
+ value: State.beatsPerBar,
+ oninput: function (e) { State.beatsPerBar = Number(e.target.value || 4) },
+ })]),
+ m("label", ["Bars / line ", m("input[type=number]#barsperline", {
+ min: 1,
+ step: 1,
+ value: State.barsPerLine,
+ oninput: function (e) { State.barsPerLine = Number(e.target.value || 2) },
+ })]),
+ m("label", ["Target stress beat ", m("input[type=number]#targetstressbeat", {
+ min: 1,
+ step: 1,
+ value: State.targetStressBeat,
+ oninput: function (e) { State.targetStressBeat = Number(e.target.value || 3) },
+ })]),
+ ]),
+ m("div#genders", [
+ m("select#gender-phonemes", { onchange: function () { State.phonemeGender = this.value } }, [
+ m("option[value=f]", { selected: State.phonemeGender == "f" }, "Female"),
+ m("option[value=m]", { selected: State.phonemeGender == "m" }, "Male"),
+ ]),
+ ]),
+ m("div#languages", [renderLanguageSelect("language-phonemes", State.phonemeLanguage, function () { State.phonemeLanguage = this.value })]),
+ m("div#overrides", [
+ m("label[for=phoneme-overrides]", "ARPABET overrides"),
+ m("input#phoneme-overrides[placeholder=word:PH1_PH2;other:PH1_PH2]", {
+ value: State.phonemeOverrides,
+ oninput: function (e) { State.phonemeOverrides = e.target.value },
+ }),
+ ]),
+ m("button", { onclick: function () { State.retrievephonemes(true) } }, "Preview phonemes"),
+ m("button", { onclick: function () { State.retrievephonemes(false) } }, "Fetch phonemes"),
+ m("br"),
+ m("br"),
+ m("div.help", [
+ m("img", { src: "assets/help.png", height: "32", title: "Help" }),
+ m("p.explanation", [
+ "Phoneme samples stay separated by word, while stress still shapes the sample boundaries.",
+ m("br"),
+ "Beat settings control how the Strudel phrase is padded with rests so each sample starts on a beat.",
+ m("br"),
+ "Preview first to get the text output immediately; generate audio when you want the WAV banks.",
+ m("br"),
+ "The result shows IPA, ARPABET, Strudel chunks, and the generated audio banks.",
+ m("br"),
+ m("br"),
+ "In a hurry? You can directly include phonemes in Strudel by executing in its terminal:",
+ m("pre", "samples('shabda/phonemes/en-GB/f:hello_world')"),
+ ]),
+ ]),
]),
m("div.pack-container", [
- m("h2", "Samples"),
+ m("h2", activeType == "phonemes" ? "Phonemes" : "Samples"),
m("div.pack",
- State.error ? m("span.error", State.error) :
- State.progress ?
+ activeResult.error ? m("span.error", activeResult.error) :
+ activeResult.progress ?
m("img", { src: "assets/infinity.svg" }) :
- State.lastretrieved ?
- m("div.latest", [
- m("small", "Insert in Estuary terminal: "),
- m("pre", State.lastreslist()),
- m("img", { onclick: function () { State.copyreslist() }, src: "assets/clipboard.png", height: "16" }),
- State.copied ? m("span.copied", "copied") : null,
- m("br"),
- m("br"),
- m("small", "Insert in Strudel: "),
- m("pre", State.lastreslist(true)),
- m("img", { onclick: function () { State.copyreslist(true) }, src: "assets/clipboard.png", height: "16" }),
- State.strudelcopied ? m("span.copied", "copied") : null,
- m('ul.samples', function () {
- soundlist = []
- for (index in State.lastreslistcontents) {
- sound = State.lastreslistcontents[index]
- soundlist.push(
- m('li',
- m('audio', { controls: true, src: sound.url }),
- " ",
- m('span.attribution',
- sound.licensename ? State.licensefullname(sound.licensename) + ' by ' + sound.author + ' ' : '',
- sound.original_url ? m('a', { 'href': sound.original_url }, m('img', { src: 'assets/external-link.png' })) : ''
- )
+ activeResult.retrieved ?
+ activeType == "phonemes" ?
+ m("div.latest.phoneme-results", [
+ renderCopyControls("phonemes"),
+ m("div.phoneme-sentences", (activeContents.sentences_phonetic || []).map(renderPhonemeSentence)),
+ m("div.phoneme-strudel", [
+ m("h3", "Timed Strudel lines"),
+ m("pre", m("code", toQuotedStringList(flattenStringLists(activeContents.sentences_strudel_timed)))),
+ m("h3", "Strudel chunks"),
+ m("pre", m("code", toQuotedStringList(activeContents.sentences_strudel || []))),
+ ]),
+ m("div.phoneme-audio", [
+ m("h3", "Generated audio"),
+ phonemeBanks.length ? phonemeBanks.map(function (bank) {
+ return renderPhonemeBank(bank, activeContents[bank] || [])
+ }) : [
+ m("p", "Preview mode does not synthesize audio banks yet."),
+ m("button", { onclick: function () { State.retrievephonemes(false) } }, "Fetch phonemes"),
+ ],
+ ]),
+ !State.phonemePreview && phonemeBanks.length
+ ? m("a.button", { href: State.phonemezipurl() }, "Download all")
+ : null,
+ ]) :
+ m("div.latest", [
+ renderCopyControls(activeType),
+ m("ul.samples", activeContents.map(function (sound) {
+ return m("li", [
+ m("audio", { controls: true, src: sound.url }),
+ " ",
+ m("span.attribution",
+ sound.licensename ? State.licensefullname(sound.licensename) + " by " + sound.author + " " : "",
+ sound.original_url ? m("a", { href: sound.original_url }, m("img", { src: "assets/external-link.png" })) : ""
)
- )
- }
- return soundlist
- }()),
- m("a.button", { href: State.lastretrievedzip() }, "Download all"),
- ]) :
- m("i", "Fetch a definition to hear its rendition")
+ ])
+ })),
+ m("a.button", { href: State.lastretrievedzip(activeType) }, "Download all"),
+ ]) :
+ m("i", activeType == "phonemes" ? "Fetch a definition to inspect its phonemes" : "Fetch a definition to hear its rendition")
)
]),
- ),
-
- m('p.footer',
- [
- m('a', { href: "https://github.com/ilesinge/shabda", title: "Source code" }, m('img', { src: "assets/github.png", height: "16px" })),
- ' by ',
- m('a', { href: "https://mastodon.sdf.org/@detour" }, 'ilesinge')
- ]
- )
+ ]),
+
+ m("p.footer", [
+ m("a", { href: "https://github.com/ilesinge/shabda", title: "Source code" }, m("img", { src: "assets/github.png", height: "16px" })),
+ " by ",
+ m("a", { href: "https://mastodon.sdf.org/@detour" }, "ilesinge"),
+ ]),
])
- }
+ },
}
+
m.mount(root, Shabda)
State.pollStatus()
setInterval(State.pollStatus, 30000)
diff --git a/pyproject.toml b/pyproject.toml
index 685b6d4..9e85704 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ flask = {extras = ["async"], version = "*"}
gunicorn = "*"
typer = {extras = ["all"], version = "*"}
google-cloud-texttospeech = "*"
+pronouncing = "*"
[tool.poetry.group.dev.dependencies]
pylint = "*"
diff --git a/shabda/dj.py b/shabda/dj.py
index ec2c52e..3068f06 100755
--- a/shabda/dj.py
+++ b/shabda/dj.py
@@ -9,6 +9,8 @@
import re
import asyncio
import urllib
+import logging
+import html
import freesound
import pydub
@@ -22,6 +24,9 @@
import shabda.chatter as chatter
+logger = logging.getLogger(__name__)
+
+
class Dj:
"""Shabda engine"""
@@ -106,13 +111,13 @@ def list(
max_number, licenses=licenses, gender=gender, language=language
)
- async def speak(self, word, language, gender):
+ async def speak(self, word, language, gender, force=False):
"""Speak a word"""
# Only allow safe characters: letters, digits, underscore, dash
language_sanitized = re.sub(r'[^\w\-]', '', language)
sampleset = SampleSet(word, TTS, self.speech_samples_path)
existing_samples = sampleset.list(language=language_sanitized, gender=gender)
- if len(existing_samples) > 0:
+ if len(existing_samples) > 0 and not force:
return True
word_dir = sampleset.dir()
client = texttospeech.TextToSpeechClient()
@@ -158,6 +163,143 @@ async def speak(self, word, language, gender):
sampleset.saveconfig()
return True
+ async def speak_phoneme(self, arpabet_phone, ipa, language, gender, force=False):
+ """Synthesise a single phoneme via SSML and store as a TTS sample"""
+ key = "ph_" + arpabet_phone
+ sampleset = SampleSet(key, TTS, self.speech_samples_path)
+ existing_samples = sampleset.list(language=language, gender=gender)
+ if not force and any(
+ os.path.exists(sample.file) and os.path.getsize(sample.file) > 128
+ for sample in existing_samples
+ ):
+ return True
+ word_dir = sampleset.dir()
+ client = texttospeech.TextToSpeechClient()
+ ssml = f'