Add iOS 'Enable Audio' CTA for Motif playback.

This commit is contained in:
b1rdmania
2025-12-18 23:21:41 +00:00
parent 234f6e38b5
commit 9eb40ce614
4 changed files with 186 additions and 23 deletions
+23 -10
View File
@@ -2,6 +2,7 @@ import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { MotifEngine } from './core/MotifEngine';
import { unlockAudio } from './utils/audioUnlock';
import type { NoteEvent } from './types';
type SearchResult = {
@@ -42,10 +43,19 @@ async function main(): Promise<void> {
const motifVol = qs<HTMLInputElement>('motifVol');
const midiService = new MIDIService();
const audioContext = new AudioContext();
const previewPlayer = new SoundfontMIDIPlayer(audioContext);
const motifEngine = new MotifEngine();
// Lazily created for iOS compatibility
let previewPlayer: SoundfontMIDIPlayer | null = null;
async function ensurePlayer(): Promise<SoundfontMIDIPlayer> {
if (!previewPlayer) {
const audioContext = await unlockAudio();
previewPlayer = new SoundfontMIDIPlayer(audioContext);
}
return previewPlayer;
}
let results: SearchResult[] = [];
let selectedIndex = 0;
let currentEvents: NoteEvent[] | null = null;
@@ -60,7 +70,7 @@ async function main(): Promise<void> {
}
function stopEverything(): void {
previewPlayer.stop();
previewPlayer?.stop();
motifEngine.stop();
previewPlayBtn.disabled = currentEvents === null;
previewStopBtn.disabled = true;
@@ -83,8 +93,9 @@ async function main(): Promise<void> {
const events = MIDIParser.parseMIDI(midiBuffer);
currentEvents = events;
await previewPlayer.load(events);
previewPlayer.setVolume(clamp01(parseFloat(previewVol.value)));
const player = await ensurePlayer();
await player.load(events);
player.setVolume(clamp01(parseFloat(previewVol.value)));
setStatus(`Ready: ${r.title}`);
previewPlayBtn.disabled = false;
@@ -143,7 +154,7 @@ async function main(): Promise<void> {
previewVol.addEventListener('input', (e) => {
const v = clamp01(parseFloat((e.target as HTMLInputElement).value));
previewPlayer.setVolume(v);
previewPlayer?.setVolume(v);
});
motifVol.addEventListener('input', (e) => {
@@ -156,7 +167,8 @@ async function main(): Promise<void> {
try {
motifEngine.stop();
isMotifPlaying = false;
await previewPlayer.play();
const player = await ensurePlayer();
await player.play();
previewPlayBtn.disabled = true;
previewStopBtn.disabled = false;
motifStopBtn.disabled = true;
@@ -167,7 +179,7 @@ async function main(): Promise<void> {
});
previewStopBtn.addEventListener('click', () => {
previewPlayer.stop();
previewPlayer?.stop();
previewPlayBtn.disabled = currentEvents === null;
previewStopBtn.disabled = true;
setStatus('Preview stopped.');
@@ -177,7 +189,7 @@ async function main(): Promise<void> {
if (!currentEvents) return;
try {
// Stop preview and generate variation
previewPlayer.stop();
previewPlayer?.stop();
previewPlayBtn.disabled = false;
previewStopBtn.disabled = true;
@@ -212,7 +224,7 @@ async function main(): Promise<void> {
if (volume) {
previewVol.value = String(clamp01(parseFloat(volume)));
previewPlayer.setVolume(parseFloat(previewVol.value));
// Volume will be applied when player is created in loadIndex
}
if (motifVolume) {
motifVol.value = String(clamp01(parseFloat(motifVolume)));
@@ -240,3 +252,4 @@ async function main(): Promise<void> {
void main();
+105 -13
View File
@@ -2,15 +2,15 @@ import { MotifEngine } from './core/MotifEngine';
import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock';
import type { NoteEvent } from './types';
class MotifApp {
private motifEngine: MotifEngine;
private midiService: MIDIService;
private audioContext: AudioContext;
// Preview player
private soundfontPlayer: SoundfontMIDIPlayer;
// Preview player (lazily created for iOS compatibility)
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
private searchBtn!: HTMLButtonElement;
private songInput!: HTMLInputElement;
@@ -39,6 +39,11 @@ class MotifApp {
private motifDuration!: HTMLElement;
private motifProgressInterval: number | null = null;
// iOS audio unlock UI (Motif only)
private iosAudioBanner!: HTMLElement;
private enableAudioBtn!: HTMLButtonElement;
private iosAudioState!: HTMLElement;
private nextResultBtn!: HTMLButtonElement;
// Embed snippet UI
@@ -52,18 +57,26 @@ class MotifApp {
private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
constructor() {
// Create AudioContext lazily on first use for iOS compatibility
this.audioContext = new AudioContext();
this.motifEngine = new MotifEngine();
this.midiService = new MIDIService();
// Initialize preview player
this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext);
// soundfontPlayer created lazily on first play for iOS compatibility
this.initializeUI();
this.setupEventListeners();
}
/**
* Ensure audio is unlocked and soundfontPlayer is ready.
* Must be called from a user gesture context.
*/
private async ensureAudioReady(): Promise<SoundfontMIDIPlayer> {
const audioContext = await unlockAudio();
if (!this.soundfontPlayer) {
this.soundfontPlayer = new SoundfontMIDIPlayer(audioContext);
}
return this.soundfontPlayer;
}
private initializeUI(): void {
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
this.songInput = document.getElementById('songInput') as HTMLInputElement;
@@ -93,6 +106,11 @@ class MotifApp {
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
// iOS audio unlock UI (Motif)
this.iosAudioBanner = document.getElementById('iosAudioBanner')!;
this.enableAudioBtn = document.getElementById('enableAudioBtn') as HTMLButtonElement;
this.iosAudioState = document.getElementById('iosAudioState')!;
// Optional embed UI (only present on main page)
this.embedSection = document.getElementById('embedSection');
this.embedCodeEl = document.getElementById('embedCode');
@@ -114,7 +132,7 @@ class MotifApp {
this.soundfontStopBtn.addEventListener('click', () => this.handleSoundfontStop());
this.soundfontVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value);
this.soundfontPlayer.setVolume(volume);
this.soundfontPlayer?.setVolume(volume);
});
// Motif
@@ -136,6 +154,70 @@ class MotifApp {
// Embed snippet copy (may be disabled / not-live)
this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet());
// iOS audio unlock CTA — must be a user gesture
const enable = () => void this.handleEnableAudio();
this.enableAudioBtn.addEventListener('click', enable);
this.enableAudioBtn.addEventListener('touchend', enable, { passive: true });
}
private isIOSLike(): boolean {
const ua = navigator.userAgent || '';
const iOS = /iPad|iPhone|iPod/.test(ua);
const iPadOS13Plus = /Macintosh/.test(ua) && (navigator as any).maxTouchPoints > 1;
return iOS || iPadOS13Plus;
}
private updateIOSAudioBanner(): void {
// Only show this UX on iOS-like browsers, and only until audio is running.
if (!this.isIOSLike()) {
this.iosAudioBanner.style.display = 'none';
return;
}
const ready = isAudioReady();
this.iosAudioBanner.style.display = ready ? 'none' : 'block';
// Optional tiny state readout (helps support debugging)
const ctx = (() => {
try { return getAudioContext(); } catch { return null; }
})();
if (!ready && ctx) {
this.iosAudioState.style.display = 'block';
this.iosAudioState.textContent = `Audio: ${ctx.state} @ ${ctx.sampleRate}Hz`;
} else {
this.iosAudioState.style.display = 'none';
this.iosAudioState.textContent = '';
}
}
private async handleEnableAudio(): Promise<void> {
// Must run in a user gesture context.
try {
this.enableAudioBtn.disabled = true;
this.iosAudioState.style.display = 'block';
this.iosAudioState.textContent = 'Audio: enabling…';
await unlockAudio();
// Update banner state
const ctx = getAudioContext();
if (ctx.state !== 'running') {
this.enableAudioBtn.disabled = false;
this.iosAudioState.textContent = 'Audio still locked. Tap Enable Audio again.';
return;
}
this.iosAudioState.textContent = `Audio: running @ ${ctx.sampleRate}Hz`;
// Hide after a short beat to reduce flicker
window.setTimeout(() => this.updateIOSAudioBanner(), 250);
} catch {
this.enableAudioBtn.disabled = false;
this.iosAudioState.style.display = 'block';
this.iosAudioState.textContent = 'Audio enable failed. Tap again, or disable Silent Mode.';
} finally {
this.enableAudioBtn.disabled = false;
}
}
private async handleSearch(): Promise<void> {
@@ -171,6 +253,7 @@ class MotifApp {
this.displayResults();
this.updateStatus(`Found ${results.length} MIDI files. Select one to play.`);
this.updateIOSAudioBanner();
} catch (error) {
this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -245,8 +328,9 @@ class MotifApp {
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
// Load into preview player
await this.soundfontPlayer.load(events);
// Load into preview player (ensure audio unlocked on iOS)
const player = await this.ensureAudioReady();
await player.load(events);
// Update UI
this.selectedTitle.textContent = result.title;
@@ -259,6 +343,7 @@ class MotifApp {
`;
this.updateEmbedSnippet(result.title);
this.updateIOSAudioBanner();
this.playerSection.classList.add('visible');
this.enablePlayerControls();
@@ -273,17 +358,20 @@ class MotifApp {
private async handleSoundfontPlay(): Promise<void> {
if (!this.currentMIDI) return;
try {
await this.soundfontPlayer.play();
// Ensure audio is unlocked (user gesture context)
const player = await this.ensureAudioReady();
await player.play();
this.soundfontPlayBtn.disabled = true;
this.soundfontStopBtn.disabled = false;
this.updateStatus('Previewing MIDI...');
} catch (error) {
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
this.updateIOSAudioBanner();
}
private handleSoundfontStop(): void {
this.soundfontPlayer.stop();
this.soundfontPlayer?.stop();
this.soundfontPlayBtn.disabled = false;
this.soundfontStopBtn.disabled = true;
this.updateStatus('Preview stopped.');
@@ -298,6 +386,10 @@ class MotifApp {
}
try {
// Best-effort: ensure iOS audio is unlocked from this user gesture.
await unlockAudio();
this.updateIOSAudioBanner();
this.updateStatus('Generating Motif synthesis...');
this.motifBtn.disabled = true;