Skip to content

Commit 4b2d0c0

Browse files
committed
feat: stabilize focus playback and add mp3 export
1 parent 8014f52 commit 4b2d0c0

File tree

13 files changed

+701
-219
lines changed

13 files changed

+701
-219
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added
10+
- Export: MP3 export via external `TiMidity++ -> FFmpeg (libmp3lame)` pipeline (`File -> Export -> MP3…`) with runtime availability checks.
11+
- Settings: configurable paths for MP3 toolchain binaries (`MP3 export: TiMidity++ path`, `MP3 export: FFmpeg path`) with PATH auto-detection fallback.
12+
13+
### Changed
14+
- Linux portal save dialogs: enabled filename-preserving defaults for export/save flows so suggested names are applied consistently.
15+
- MIDI input popover wording clarified (`Preview volume (input + typing)`, `MIDI preview ms`) to match actual behavior.
16+
17+
### Fixed
18+
- Focus playback muting: voice muting now applies at parsed-symbol level, remains deterministic in multi-voice tunes, and correctly supports muting `V:1` (including implicit first-voice mapping when explicit `V:1` is missing/malformed).
19+
- Focus playback robustness: fixed no-sound failures caused by muted-voice preprocessing edge cases.
20+
- Typing note preview: inline field directives like `[P:...]` / `[K:...]` / `[V:...]` no longer trigger note preview.
21+
- Preview loudness consistency: MIDI-input preview and typing preview volume are now synchronized (UI + persisted settings), removing mismatched perceived loudness.
22+
- Export dialogs: hardened MusicXML save dialog error handling and aligned suggested-name behavior across MusicXML/MIDI/MP3/PDF/settings export.
23+
24+
### Tests
25+
- Focus playback harness extended for muted-voice invariance and first-voice fallback (`V:1` implicit mapping).
26+
- Note preview harness extended with regression coverage for inline field suppression (`[P:...]` should not sound).
927

1028

1129
## [0.32.3] - 2026-02-14

devtools/focus_playback_harness/run_tests.js

Lines changed: 110 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -640,14 +640,6 @@ function normalizeVoiceIdToken(value) {
640640
return head ? String(head).trim() : "";
641641
}
642642

643-
function isLikelyMusicBodyLine(line) {
644-
const trimmed = String(line || "").trim();
645-
if (!trimmed) return false;
646-
if (trimmed.startsWith("%") || /^%%/.test(trimmed)) return false;
647-
if (/^[A-Za-z][A-Za-z0-9_-]*\s*:/.test(trimmed)) return false;
648-
return /[A-Ga-gxzZ]/.test(trimmed) || /[|]/.test(trimmed);
649-
}
650-
651643
function parseMutedVoiceSetting(value) {
652644
const raw = String(value || "").trim();
653645
if (!raw) return [];
@@ -662,53 +654,82 @@ function parseMutedVoiceSetting(value) {
662654
return out;
663655
}
664656

665-
function stripMutedVoicesForPlayback(text, mutedVoices) {
666-
const muted = mutedVoices && typeof mutedVoices === "object"
667-
? Object.entries(mutedVoices)
668-
.filter(([, v]) => Boolean(v))
669-
.map(([k]) => normalizeVoiceIdToken(k))
670-
.filter(Boolean)
671-
: [];
672-
if (!muted.length) return text;
673-
if (/\[V\s*:/i.test(text)) return String(text || "");
674-
const mutedSet = new Set(muted);
675-
const lines = String(text || "").split(/\r\n|\n|\r/);
676-
let inBody = false;
677-
let currentVoice = null;
678-
let firstVoiceId = null;
679-
const registerFirstVoice = (id) => {
680-
if (firstVoiceId) return;
681-
firstVoiceId = String(id || "").trim() || "1";
682-
if (mutedSet.has("1")) mutedSet.add(firstVoiceId);
683-
};
684-
const out = [];
685-
for (const line of lines) {
686-
const trimmed = line.trim();
687-
if (!inBody && /^K:/.test(trimmed)) {
688-
inBody = true;
689-
out.push(line);
690-
continue;
691-
}
692-
if (!inBody) {
693-
out.push(line);
694-
continue;
695-
}
696-
const voiceLine = line.match(/^\s*V\s*:\s*(.*)$/i);
697-
if (voiceLine) {
698-
currentVoice = normalizeVoiceIdToken(voiceLine[1]) || "1";
699-
registerFirstVoice(currentVoice);
700-
if (mutedSet.has(currentVoice)) continue;
701-
out.push(line);
702-
continue;
657+
function resolveEffectiveMutedVoiceIds(mutedVoiceIds, firstPlayableVoiceId) {
658+
const ids = Array.isArray(mutedVoiceIds) ? mutedVoiceIds.map((v) => normalizeVoiceIdToken(v)).filter(Boolean) : [];
659+
if (!ids.length) return [];
660+
const firstId = normalizeVoiceIdToken(firstPlayableVoiceId);
661+
const set = new Set(ids);
662+
if (set.has("1") && firstId) set.add(firstId);
663+
return Array.from(set);
664+
}
665+
666+
function getFirstPlayableVoiceIdFromTuneRoot(firstSymbol) {
667+
let s = firstSymbol || null;
668+
let guard = 0;
669+
while (s && s.ts_prev && guard < 200000) {
670+
s = s.ts_prev;
671+
guard += 1;
672+
}
673+
guard = 0;
674+
while (s && guard < 200000) {
675+
const pv = s.p_v || null;
676+
const id = pv && pv.id != null ? String(pv.id) : "";
677+
const upper = id.toUpperCase();
678+
if (id && upper !== "_DRUM" && upper !== "_CHORD" && upper !== "_BEATS") return id;
679+
s = s.ts_next;
680+
guard += 1;
681+
}
682+
return "";
683+
}
684+
685+
function applyMutedVoicesToTuneRoot(firstSymbol, mutedVoiceIds) {
686+
const mutedSet = new Set(Array.isArray(mutedVoiceIds) ? mutedVoiceIds.map((v) => String(v)) : []);
687+
if (!firstSymbol || !mutedSet.size) return false;
688+
let s = firstSymbol;
689+
let guard = 0;
690+
while (s && s.ts_prev && guard < 200000) {
691+
s = s.ts_prev;
692+
guard += 1;
693+
}
694+
let changed = false;
695+
guard = 0;
696+
while (s && guard < 400000) {
697+
const pv = s.p_v || null;
698+
const id = pv && pv.id != null ? String(pv.id) : "";
699+
if (id && mutedSet.has(id)) {
700+
s.noplay = true;
701+
changed = true;
702+
if (Array.isArray(s.notes)) {
703+
for (const note of s.notes) {
704+
if (note && typeof note === "object") note.noplay = true;
705+
}
706+
}
703707
}
704-
if (!currentVoice && isLikelyMusicBodyLine(line)) {
705-
currentVoice = "1";
706-
registerFirstVoice(currentVoice);
708+
s = s.ts_next;
709+
guard += 1;
710+
}
711+
return changed;
712+
}
713+
714+
function countPlayableByVoice(firstSymbol) {
715+
const out = new Map();
716+
let s = firstSymbol;
717+
let guard = 0;
718+
while (s && s.ts_prev && guard < 200000) {
719+
s = s.ts_prev;
720+
guard += 1;
721+
}
722+
guard = 0;
723+
while (s && guard < 400000) {
724+
const playable = !s.noplay && Number.isFinite(s.dur) && s.dur > 0;
725+
if (playable) {
726+
const id = (s.p_v && s.p_v.id != null) ? String(s.p_v.id) : "";
727+
if (id) out.set(id, (out.get(id) || 0) + 1);
707728
}
708-
if (currentVoice && mutedSet.has(currentVoice)) continue;
709-
out.push(line);
729+
s = s.ts_next;
730+
guard += 1;
710731
}
711-
return out.join("\n");
732+
return out;
712733
}
713734

714735
function shiftByNumberMap(byNumber, renderOffset) {
@@ -948,19 +969,31 @@ async function main() {
948969
process.exitCode = 1;
949970
}
950971

951-
// Muted voices parsing / filtering regression tests.
972+
// Muted voices parsing / symbol-level muting regression tests.
952973
try {
953974
const ids = parseMutedVoiceSetting("2, 3 2");
954975
assert(ids.length === 2 && ids[0] === "2" && ids[1] === "3", "parseMutedVoiceSetting should dedupe");
955976

956-
const muted23 = stripMutedVoicesForPlayback(tuneText, { "2": true, "3": true });
957-
assert(/\nV:1\b/.test(`\n${muted23}`), "muted 2,3 should keep V:1");
958-
assert(!/\nV:2\b/.test(`\n${muted23}`), "muted 2,3 should remove V:2");
959-
assert(!/\nV:3\b/.test(`\n${muted23}`), "muted 2,3 should remove V:3");
960-
961-
const muted1 = stripMutedVoicesForPlayback(tuneText, { "1": true });
962-
assert(!/\nV:1\b/.test(`\n${muted1}`), "muted 1 should remove V:1");
963-
assert(/\nV:2\b/.test(`\n${muted1}`), "muted 1 should keep V:2");
977+
const baseRoot = parseTuneWithAbc2svg(tuneText);
978+
const baseCounts = countPlayableByVoice(baseRoot);
979+
assert((baseCounts.get("1") || 0) > 0, "fixture must contain playable V:1 symbols");
980+
assert((baseCounts.get("2") || 0) > 0, "fixture must contain playable V:2 symbols");
981+
982+
const mutedV1Root = parseTuneWithAbc2svg(tuneText);
983+
const firstId = getFirstPlayableVoiceIdFromTuneRoot(mutedV1Root);
984+
const effectiveMuteV1 = resolveEffectiveMutedVoiceIds(["1"], firstId);
985+
const changedV1 = applyMutedVoicesToTuneRoot(mutedV1Root, effectiveMuteV1);
986+
assert(changedV1, "muted V:1 should modify tune symbols");
987+
const afterV1 = countPlayableByVoice(mutedV1Root);
988+
assert((afterV1.get("1") || 0) === 0, "muted V:1 must silence voice 1");
989+
assert((afterV1.get("2") || 0) > 0, "muted V:1 must keep voice 2 playable");
990+
991+
const mutedV2Root = parseTuneWithAbc2svg(tuneText);
992+
const changedV2 = applyMutedVoicesToTuneRoot(mutedV2Root, ["2"]);
993+
assert(changedV2, "muted V:2 should modify tune symbols");
994+
const afterV2 = countPlayableByVoice(mutedV2Root);
995+
assert((afterV2.get("2") || 0) === 0, "muted V:2 must silence voice 2");
996+
assert((afterV2.get("1") || 0) > 0, "muted V:2 must keep voice 1 playable");
964997

965998
const implicitVoiceText = [
966999
"X:1",
@@ -972,9 +1005,13 @@ async function main() {
9721005
"V:2",
9731006
"A2 B2 | c2 d2 |",
9741007
].join("\n");
975-
const implicitMuted1 = stripMutedVoicesForPlayback(implicitVoiceText, { "1": true });
976-
assert(!/D2 E2/.test(implicitMuted1), "implicit V:1 content must be muted when voice 1 is muted");
977-
assert(/V:2/.test(implicitMuted1), "implicit V:1 mute should keep explicit V:2");
1008+
const implicitRoot = parseTuneWithAbc2svg(implicitVoiceText);
1009+
const implicitFirst = getFirstPlayableVoiceIdFromTuneRoot(implicitRoot);
1010+
const implicitEffective = resolveEffectiveMutedVoiceIds(["1"], implicitFirst);
1011+
assert(implicitEffective.includes(implicitFirst), "implicit/malformed V:1 should map to de-facto first voice");
1012+
applyMutedVoicesToTuneRoot(implicitRoot, implicitEffective);
1013+
const implicitAfter = countPlayableByVoice(implicitRoot);
1014+
assert((implicitAfter.get("2") || 0) > 0, "implicit V:1 mute should keep explicit V:2 playable");
9781015

9791016
const malformedVoiceText = [
9801017
"X:1",
@@ -987,12 +1024,16 @@ async function main() {
9871024
"V:2",
9881025
"A2 B2 | c2 d2 |",
9891026
].join("\n");
990-
const malformedMuted1 = stripMutedVoicesForPlayback(malformedVoiceText, { "1": true });
991-
assert(!/D2 E2/.test(malformedMuted1), "malformed V: should still be treated as de-facto V:1");
992-
assert(/V:2/.test(malformedMuted1), "malformed V: mute should keep explicit V:2");
993-
console.log("% PASS TEST 13: Muted voices (including implicit/malformed V:1) behave correctly");
1027+
const malformedRoot = parseTuneWithAbc2svg(malformedVoiceText);
1028+
const malformedFirst = getFirstPlayableVoiceIdFromTuneRoot(malformedRoot);
1029+
const malformedEffective = resolveEffectiveMutedVoiceIds(["1"], malformedFirst);
1030+
assert(malformedEffective.includes(malformedFirst), "malformed V: should still map mute 1 to de-facto first voice");
1031+
applyMutedVoicesToTuneRoot(malformedRoot, malformedEffective);
1032+
const malformedAfter = countPlayableByVoice(malformedRoot);
1033+
assert((malformedAfter.get("2") || 0) > 0, "malformed V: mute should keep explicit V:2 playable");
1034+
console.log("% PASS TEST 13: Muted voices (including V:1 and implicit/malformed V:1) behave correctly");
9941035
} catch (e) {
995-
console.log("% FAIL TEST 13: Muted voices (including implicit/malformed V:1) behave correctly");
1036+
console.log("% FAIL TEST 13: Muted voices (including V:1 and implicit/malformed V:1) behave correctly");
9961037
String(e && e.message ? e.message : e).split(/\r\n|\n|\r/).forEach((line) => console.log(`% ${line}`));
9971038
process.exitCode = 1;
9981039
}

devtools/note_preview_harness/run_tests.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
findCompletedNoteTokenBeforePosition,
3+
isRangeInsideInlineField,
34
parseAbcNoteToken,
45
parseHeadersNear,
56
} from "../../src/renderer/note_preview/abc_note_parse.mjs";
@@ -64,6 +65,15 @@ function run() {
6465
const micro = parseAbcNoteToken("^3c", ctx, { lengthMode: "typed", skipMicrotones: true });
6566
assert(micro === null, "microtonal-like token should be skipped when skipMicrotones=true");
6667

68+
assert(
69+
isRangeInsideInlineField("[P:A] C D", 3, 4) === true,
70+
"note-like letters inside [P:...] must not be previewed",
71+
);
72+
assert(
73+
isRangeInsideInlineField("[K:D] C D", 6, 7) === false,
74+
"musical tokens outside inline fields should remain previewable",
75+
);
76+
6777
console.log("[note_preview_harness] OK");
6878
}
6979

src/main/index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ function prepareDialogParent(senderOrEvent, reason) {
466466
return parent;
467467
}
468468

469-
function getDialogDefaultPath({ suggestedName, suggestedDir, suggestedPath, directoryOnly } = {}) {
469+
function getDialogDefaultPath({ suggestedName, suggestedDir, suggestedPath, directoryOnly, preferFileNameOnPortal = false } = {}) {
470470
const normalizeFsPath = (value) => {
471471
const raw = String(value || "").trim();
472472
if (!raw) return "";
@@ -492,7 +492,7 @@ function getDialogDefaultPath({ suggestedName, suggestedDir, suggestedPath, dire
492492
const fileName = String(suggestedName || "").trim() || explicitPathBase;
493493
// Linux portal dialogs often ignore defaultPath when a filename is included.
494494
// Prefer a directory default there to keep navigation stable.
495-
if (portalLikelyActive && baseDir) return baseDir;
495+
if (portalLikelyActive && baseDir && !preferFileNameOnPortal) return baseDir;
496496
if (baseDir && fileName) return path.join(baseDir, fileName);
497497
if (baseDir) return baseDir;
498498
if (explicitPathAbs) return explicitPathAbs;
@@ -554,6 +554,7 @@ function showSaveDialog(suggestedName, suggestedDir, senderOrEvent) {
554554
const defaultPath = getDialogDefaultPath({
555555
suggestedName: defaultName,
556556
suggestedDir,
557+
preferFileNameOnPortal: true,
557558
});
558559
const ext = path.extname(defaultName || "").replace(/^\./, "").trim().toLowerCase();
559560
const filters = (() => {
@@ -1106,6 +1107,8 @@ function applySettingsPatch(patch, { persistToSettingsFile = true } = {}) {
11061107
Object.prototype.hasOwnProperty.call(patch, "makamToolsEnabled")
11071108
|| Object.prototype.hasOwnProperty.call(patch, "studyToolsEnabled")
11081109
|| Object.prototype.hasOwnProperty.call(patch, "payloadModeEnabled")
1110+
|| Object.prototype.hasOwnProperty.call(patch, "mp3ExportTimidityPath")
1111+
|| Object.prototype.hasOwnProperty.call(patch, "mp3ExportFfmpegPath")
11091112
)) {
11101113
refreshMenu();
11111114
}

0 commit comments

Comments
 (0)