Skip to content

Commit 293d12d

Browse files
committed
Add audible Preview Synth playback for MIDI Studio V2 example and grid data - PR_26146_016-midi-studio-v2-audible-preview-engine
1 parent a53477c commit 293d12d

8 files changed

Lines changed: 473 additions & 34 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# PR_26146_016-midi-studio-v2-audible-preview-engine Validation
2+
3+
## Scope
4+
5+
- Added shared `src/engine/audio/PreviewSynthEngine.js` for lightweight oscillator-based grid preview playback.
6+
- Preserved rendered OGG/MP3/WAV preview behavior and did not claim SoundFont, imported `.mid`, or rendered export playback support.
7+
- Wired MIDI Studio V2 Play Section and Play Loop to start Preview Synth only after valid normalized grid events are present.
8+
- Stop now clears scheduled Preview Synth oscillators while preserving playhead/timing preview behavior.
9+
- Added actionable failure states for no playable grid notes and unavailable Web Audio.
10+
11+
## Validation
12+
13+
- PASS: changed-file syntax checks with `node --check`.
14+
- PASS: MIDI Studio V2 inline HTML check for inline scripts, styles, and event handlers returned no matches.
15+
- PASS: targeted MIDI Studio V2 Playwright suite: `34 passed`.
16+
- PASS: `git diff --check` completed successfully. Git reported line-ending warnings for existing LF/CRLF normalization only.
17+
- SKIP: full samples smoke test per request. Samples decision: SKIP because sample JSON alignment is out of scope.
18+
19+
## Lanes
20+
21+
- Executed: engine/audio shared runtime, because Preview Synth lives under `src/engine/audio/`.
22+
- Executed: recovery/UAT tool runtime, because MIDI Studio V2 transport behavior and user-facing statuses changed.
23+
- Skipped: samples, because sample JSON alignment remains out of scope.
24+
25+
## Manual Check
26+
27+
1. Open MIDI Studio V2.
28+
2. Choose `Use Example Test Song`.
29+
3. Normalize or generate grid lanes, then choose `Play Section` or `Play Loop`.
30+
4. Confirm status reports `Preview Synth started...` and identifies oscillator playback as not SoundFont playback.
31+
5. Choose `Stop` and confirm the status reports scheduled oscillators were cleared.

src/engine/audio/InstrumentGridParser.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export class InstrumentGridParser {
217217
lane,
218218
section: timing.section,
219219
source: tokenSource,
220+
stepIndex: barIndex * beatsPerBar * subdivision + stepIndex,
220221
subdivision: timing.subdivision,
221222
subdivisionStep: timing.subdivisionStep,
222223
token: value
@@ -270,7 +271,9 @@ export class InstrumentGridParser {
270271
lane: cell.lane,
271272
section: cell.section,
272273
source: cell.source,
274+
stepIndex: cell.stepIndex,
273275
subdivision: cell.subdivision,
276+
subdivisionStep: cell.subdivisionStep,
274277
value
275278
};
276279
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
const CHORD_TONES = {
2+
A: ["A", "C#", "E"],
3+
Am: ["A", "C", "E"],
4+
B: ["B", "D#", "F#"],
5+
Bm: ["B", "D", "F#"],
6+
C: ["C", "E", "G"],
7+
Cm: ["C", "Eb", "G"],
8+
D: ["D", "F#", "A"],
9+
Dm: ["D", "F", "A"],
10+
E: ["E", "G#", "B"],
11+
Em: ["E", "G", "B"],
12+
F: ["F", "A", "C"],
13+
Fm: ["F", "Ab", "C"],
14+
G: ["G", "B", "D"],
15+
Gm: ["G", "Bb", "D"]
16+
};
17+
18+
const DRUM_FREQUENCIES = {
19+
clap: 920,
20+
crash: 1100,
21+
hat: 760,
22+
kick: 72,
23+
perc: 640,
24+
ride: 980,
25+
snare: 230,
26+
tom: 150
27+
};
28+
29+
function audioContextCtor(windowRef) {
30+
return windowRef?.AudioContext || windowRef?.webkitAudioContext || null;
31+
}
32+
33+
function noteFrequency(noteName) {
34+
const match = String(noteName || "").trim().match(/^([A-G])(#|b)?([0-8])$/);
35+
if (!match) {
36+
return null;
37+
}
38+
const offsets = { C: -9, D: -7, E: -5, F: -4, G: -2, A: 0, B: 2 };
39+
const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
40+
const semitonesFromA4 = offsets[match[1]] + accidental + (Number(match[3]) - 4) * 12;
41+
return 440 * (2 ** (semitonesFromA4 / 12));
42+
}
43+
44+
function chordNotes(chordName) {
45+
const tones = CHORD_TONES[String(chordName || "").trim()];
46+
return tones ? tones.map((tone) => `${tone}4`) : [];
47+
}
48+
49+
export class PreviewSynthEngine {
50+
constructor({ windowRef = globalThis } = {}) {
51+
this.context = null;
52+
this.loopTimer = null;
53+
this.nodes = [];
54+
this.playing = false;
55+
this.window = windowRef;
56+
}
57+
58+
isSupported() {
59+
return Boolean(audioContextCtor(this.window));
60+
}
61+
62+
async playGridRange({ endStep, grid, label, loop = false, mode = "section", startStep, tempoBpm = 120 } = {}) {
63+
this.stop();
64+
if (!grid?.ok) {
65+
return { message: "Preview Synth needs a normalized instrument grid before playback.", ok: false, reason: "missing-grid" };
66+
}
67+
const contextResult = await this.ensureContext();
68+
if (!contextResult.ok) {
69+
return contextResult;
70+
}
71+
const playableEvents = this.playableEventsForRange(grid, startStep, endStep);
72+
if (!playableEvents.length) {
73+
return {
74+
message: `No playable Preview Synth notes found for ${mode} ${label || "(unnamed)"}. Generate or enter chords, bass, pad, lead, or drum cells before playing.`,
75+
ok: false,
76+
reason: "no-playable-notes"
77+
};
78+
}
79+
const safeTempo = Number.isFinite(Number(tempoBpm)) && Number(tempoBpm) > 0 ? Number(tempoBpm) : 120;
80+
const secondsPerBeat = 60 / safeTempo;
81+
const secondsPerStep = secondsPerBeat / grid.subdivision;
82+
const cycleSeconds = Math.max((endStep - startStep + 1) * secondsPerStep, secondsPerStep);
83+
this.playing = true;
84+
this.scheduleEvents({ context: contextResult.context, events: playableEvents, secondsPerBeat, secondsPerStep, startStep });
85+
if (loop) {
86+
this.loopTimer = this.window.setInterval(() => {
87+
this.scheduleEvents({ context: contextResult.context, events: playableEvents, secondsPerBeat, secondsPerStep, startStep });
88+
}, cycleSeconds * 1000);
89+
}
90+
return {
91+
eventCount: playableEvents.length,
92+
label,
93+
mode,
94+
ok: true,
95+
soundFontPlayback: false,
96+
synthName: "Preview Synth"
97+
};
98+
}
99+
100+
async ensureContext() {
101+
const AudioContextCtor = audioContextCtor(this.window);
102+
if (!AudioContextCtor) {
103+
return {
104+
message: "Preview Synth audio unavailable: Web Audio AudioContext is not available. Use a browser with Web Audio support.",
105+
ok: false,
106+
reason: "audio-unavailable"
107+
};
108+
}
109+
try {
110+
this.context = this.context || new AudioContextCtor();
111+
if (typeof this.context.resume === "function") {
112+
await this.context.resume();
113+
}
114+
if (this.context.state === "suspended") {
115+
return {
116+
message: "Preview Synth audio is still suspended after the play gesture. Click Play Section or Play Loop again and check browser audio permissions.",
117+
ok: false,
118+
reason: "audio-suspended"
119+
};
120+
}
121+
return { context: this.context, ok: true };
122+
} catch (error) {
123+
return {
124+
message: `Preview Synth audio could not start after the play gesture: ${error.message || "browser audio permission blocked playback"}.`,
125+
ok: false,
126+
reason: "audio-resume-failed"
127+
};
128+
}
129+
}
130+
131+
playableEventsForRange(grid, startStep = 0, endStep = 0) {
132+
return (grid.timeline || []).filter((event) => {
133+
const stepIndex = Number(event.stepIndex);
134+
return Number.isFinite(stepIndex)
135+
&& stepIndex >= startStep
136+
&& stepIndex <= endStep
137+
&& this.frequenciesForEvent(event).length > 0;
138+
});
139+
}
140+
141+
scheduleEvents({ context, events, secondsPerBeat, secondsPerStep, startStep }) {
142+
const now = context.currentTime;
143+
events.forEach((event) => {
144+
const offsetSeconds = Math.max(0, (event.stepIndex - startStep) * secondsPerStep);
145+
const durationSeconds = Math.max(0.06, Number(event.durationBeats || 1) * secondsPerBeat * 0.82);
146+
this.frequenciesForEvent(event).forEach((frequency, index) => {
147+
this.scheduleTone({
148+
context,
149+
durationSeconds,
150+
event,
151+
frequency,
152+
startTime: now + offsetSeconds + index * 0.006
153+
});
154+
});
155+
});
156+
}
157+
158+
scheduleTone({ context, durationSeconds, event, frequency, startTime }) {
159+
const oscillator = context.createOscillator();
160+
const gainNode = context.createGain();
161+
const waveform = this.waveformForEvent(event);
162+
const volume = this.volumeForEvent(event);
163+
const endTime = startTime + durationSeconds;
164+
oscillator.type = waveform;
165+
oscillator.frequency.setValueAtTime(frequency, startTime);
166+
gainNode.gain.setValueAtTime(0.0001, startTime);
167+
gainNode.gain.linearRampToValueAtTime(volume, startTime + 0.015);
168+
gainNode.gain.setValueAtTime(volume, Math.max(startTime + 0.016, endTime - 0.045));
169+
gainNode.gain.linearRampToValueAtTime(0.0001, endTime);
170+
oscillator.connect(gainNode);
171+
gainNode.connect(context.destination);
172+
oscillator.start(startTime);
173+
oscillator.stop(endTime);
174+
this.nodes.push({ gainNode, oscillator });
175+
}
176+
177+
frequenciesForEvent(event) {
178+
if (event.kind === "drum") {
179+
const frequency = DRUM_FREQUENCIES[String(event.value || "").toLowerCase()];
180+
return frequency ? [frequency] : [];
181+
}
182+
if (event.kind === "chord") {
183+
return chordNotes(event.value).map((note) => noteFrequency(note)).filter(Boolean);
184+
}
185+
if (event.kind === "note") {
186+
const frequency = noteFrequency(event.value);
187+
return frequency ? [frequency] : [];
188+
}
189+
return [];
190+
}
191+
192+
waveformForEvent(event) {
193+
if (event.kind === "drum") {
194+
return event.value === "kick" || event.value === "tom" ? "sine" : "square";
195+
}
196+
return event.lane === "lead" ? "sawtooth" : event.lane === "bass" ? "triangle" : "sine";
197+
}
198+
199+
volumeForEvent(event) {
200+
if (event.kind === "drum") {
201+
return 0.12;
202+
}
203+
return event.kind === "chord" ? 0.045 : 0.075;
204+
}
205+
206+
stop() {
207+
if (this.loopTimer) {
208+
this.window.clearInterval(this.loopTimer);
209+
this.loopTimer = null;
210+
}
211+
this.nodes.forEach(({ oscillator, gainNode }) => {
212+
try {
213+
oscillator.stop();
214+
} catch {
215+
// Already stopped or never started.
216+
}
217+
try {
218+
oscillator.disconnect();
219+
gainNode.disconnect();
220+
} catch {
221+
// Some test doubles do not implement disconnect.
222+
}
223+
});
224+
const stoppedCount = this.nodes.length;
225+
this.nodes = [];
226+
this.playing = false;
227+
return stoppedCount;
228+
}
229+
230+
getSnapshot() {
231+
return {
232+
activeNodeCount: this.nodes.length,
233+
loopActive: Boolean(this.loopTimer),
234+
playing: this.playing,
235+
supported: this.isSupported()
236+
};
237+
}
238+
}

0 commit comments

Comments
 (0)