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
+1
View File
@@ -221,3 +221,4 @@
</body> </body>
</html> </html>
+57
View File
@@ -429,6 +429,53 @@
font-size: 0.85em; font-size: 0.85em;
min-width: 55px; min-width: 55px;
} }
/* iOS audio unlock (Motif) */
.ios-audio-banner {
margin-top: calc(var(--spacing-unit) * 2);
padding: calc(var(--spacing-unit) * 2);
border-radius: var(--radius);
border: 1px solid rgba(255, 170, 68, 0.22);
background: rgba(0, 0, 0, 0.25);
box-shadow: var(--shadow-sm);
display: none; /* toggled by JS */
}
.ios-audio-banner .row {
display: flex;
gap: calc(var(--spacing-unit) * 1.5);
align-items: center;
flex-wrap: wrap;
}
.ios-audio-banner .copy {
font-size: 12px;
color: var(--color-text-dim);
line-height: 1.35;
flex: 1;
min-width: 220px;
}
.ios-audio-banner .copy strong {
color: var(--color-text);
font-weight: 600;
}
.ios-audio-banner .state {
margin-top: calc(var(--spacing-unit) * 1);
font-size: 12px;
color: rgba(255, 170, 68, 0.85);
display: none;
}
.ios-audio-banner button {
padding: calc(var(--spacing-unit) * 1.25) calc(var(--spacing-unit) * 2);
border-radius: var(--radius);
border: 1px solid rgba(255, 170, 68, 0.35);
background: rgba(255, 170, 68, 0.12);
color: var(--color-text);
box-shadow: var(--shadow-sm);
font-weight: 600;
}
.ios-audio-banner button:hover:not(:disabled) {
border-color: rgba(255, 170, 68, 0.55);
box-shadow: var(--glow-secondary), var(--shadow-md);
}
.motif-kicker { .motif-kicker {
font-size: 0.85em; font-size: 0.85em;
color: #b6b6b6; color: #b6b6b6;
@@ -687,6 +734,16 @@
</div> </div>
</div> </div>
<div id="iosAudioBanner" class="ios-audio-banner" aria-live="polite">
<div class="row">
<div class="copy">
<strong>iOS Safari:</strong> tap once to enable audio, then press <strong>Generate &amp; Play</strong>.
</div>
<button id="enableAudioBtn" type="button">Enable Audio</button>
</div>
<div id="iosAudioState" class="state">Audio: …</div>
</div>
<div class="motif-progress-container" id="motifProgressContainer" style="display: none;"> <div class="motif-progress-container" id="motifProgressContainer" style="display: none;">
<div class="progress-time"> <div class="progress-time">
<span id="motifCurrentTime">0:00</span> <span id="motifCurrentTime">0:00</span>
+23 -10
View File
@@ -2,6 +2,7 @@ import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser'; import { MIDIParser } from './midi/MIDIParser';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer'; import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { MotifEngine } from './core/MotifEngine'; import { MotifEngine } from './core/MotifEngine';
import { unlockAudio } from './utils/audioUnlock';
import type { NoteEvent } from './types'; import type { NoteEvent } from './types';
type SearchResult = { type SearchResult = {
@@ -42,10 +43,19 @@ async function main(): Promise<void> {
const motifVol = qs<HTMLInputElement>('motifVol'); const motifVol = qs<HTMLInputElement>('motifVol');
const midiService = new MIDIService(); const midiService = new MIDIService();
const audioContext = new AudioContext();
const previewPlayer = new SoundfontMIDIPlayer(audioContext);
const motifEngine = new MotifEngine(); 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 results: SearchResult[] = [];
let selectedIndex = 0; let selectedIndex = 0;
let currentEvents: NoteEvent[] | null = null; let currentEvents: NoteEvent[] | null = null;
@@ -60,7 +70,7 @@ async function main(): Promise<void> {
} }
function stopEverything(): void { function stopEverything(): void {
previewPlayer.stop(); previewPlayer?.stop();
motifEngine.stop(); motifEngine.stop();
previewPlayBtn.disabled = currentEvents === null; previewPlayBtn.disabled = currentEvents === null;
previewStopBtn.disabled = true; previewStopBtn.disabled = true;
@@ -83,8 +93,9 @@ async function main(): Promise<void> {
const events = MIDIParser.parseMIDI(midiBuffer); const events = MIDIParser.parseMIDI(midiBuffer);
currentEvents = events; currentEvents = events;
await previewPlayer.load(events); const player = await ensurePlayer();
previewPlayer.setVolume(clamp01(parseFloat(previewVol.value))); await player.load(events);
player.setVolume(clamp01(parseFloat(previewVol.value)));
setStatus(`Ready: ${r.title}`); setStatus(`Ready: ${r.title}`);
previewPlayBtn.disabled = false; previewPlayBtn.disabled = false;
@@ -143,7 +154,7 @@ async function main(): Promise<void> {
previewVol.addEventListener('input', (e) => { previewVol.addEventListener('input', (e) => {
const v = clamp01(parseFloat((e.target as HTMLInputElement).value)); const v = clamp01(parseFloat((e.target as HTMLInputElement).value));
previewPlayer.setVolume(v); previewPlayer?.setVolume(v);
}); });
motifVol.addEventListener('input', (e) => { motifVol.addEventListener('input', (e) => {
@@ -156,7 +167,8 @@ async function main(): Promise<void> {
try { try {
motifEngine.stop(); motifEngine.stop();
isMotifPlaying = false; isMotifPlaying = false;
await previewPlayer.play(); const player = await ensurePlayer();
await player.play();
previewPlayBtn.disabled = true; previewPlayBtn.disabled = true;
previewStopBtn.disabled = false; previewStopBtn.disabled = false;
motifStopBtn.disabled = true; motifStopBtn.disabled = true;
@@ -167,7 +179,7 @@ async function main(): Promise<void> {
}); });
previewStopBtn.addEventListener('click', () => { previewStopBtn.addEventListener('click', () => {
previewPlayer.stop(); previewPlayer?.stop();
previewPlayBtn.disabled = currentEvents === null; previewPlayBtn.disabled = currentEvents === null;
previewStopBtn.disabled = true; previewStopBtn.disabled = true;
setStatus('Preview stopped.'); setStatus('Preview stopped.');
@@ -177,7 +189,7 @@ async function main(): Promise<void> {
if (!currentEvents) return; if (!currentEvents) return;
try { try {
// Stop preview and generate variation // Stop preview and generate variation
previewPlayer.stop(); previewPlayer?.stop();
previewPlayBtn.disabled = false; previewPlayBtn.disabled = false;
previewStopBtn.disabled = true; previewStopBtn.disabled = true;
@@ -212,7 +224,7 @@ async function main(): Promise<void> {
if (volume) { if (volume) {
previewVol.value = String(clamp01(parseFloat(volume))); previewVol.value = String(clamp01(parseFloat(volume)));
previewPlayer.setVolume(parseFloat(previewVol.value)); // Volume will be applied when player is created in loadIndex
} }
if (motifVolume) { if (motifVolume) {
motifVol.value = String(clamp01(parseFloat(motifVolume))); motifVol.value = String(clamp01(parseFloat(motifVolume)));
@@ -240,3 +252,4 @@ async function main(): Promise<void> {
void main(); void main();
+105 -13
View File
@@ -2,15 +2,15 @@ import { MotifEngine } from './core/MotifEngine';
import { MIDIService } from './services/MIDIService'; import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser'; import { MIDIParser } from './midi/MIDIParser';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer'; import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock';
import type { NoteEvent } from './types'; import type { NoteEvent } from './types';
class MotifApp { class MotifApp {
private motifEngine: MotifEngine; private motifEngine: MotifEngine;
private midiService: MIDIService; private midiService: MIDIService;
private audioContext: AudioContext;
// Preview player // Preview player (lazily created for iOS compatibility)
private soundfontPlayer: SoundfontMIDIPlayer; private soundfontPlayer: SoundfontMIDIPlayer | null = null;
private searchBtn!: HTMLButtonElement; private searchBtn!: HTMLButtonElement;
private songInput!: HTMLInputElement; private songInput!: HTMLInputElement;
@@ -39,6 +39,11 @@ class MotifApp {
private motifDuration!: HTMLElement; private motifDuration!: HTMLElement;
private motifProgressInterval: number | null = null; private motifProgressInterval: number | null = null;
// iOS audio unlock UI (Motif only)
private iosAudioBanner!: HTMLElement;
private enableAudioBtn!: HTMLButtonElement;
private iosAudioState!: HTMLElement;
private nextResultBtn!: HTMLButtonElement; private nextResultBtn!: HTMLButtonElement;
// Embed snippet UI // Embed snippet UI
@@ -52,18 +57,26 @@ class MotifApp {
private currentMIDI: { events: NoteEvent[], metadata: any } | null = null; private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
constructor() { constructor() {
// Create AudioContext lazily on first use for iOS compatibility
this.audioContext = new AudioContext();
this.motifEngine = new MotifEngine(); this.motifEngine = new MotifEngine();
this.midiService = new MIDIService(); this.midiService = new MIDIService();
// soundfontPlayer created lazily on first play for iOS compatibility
// Initialize preview player
this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext);
this.initializeUI(); this.initializeUI();
this.setupEventListeners(); 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 { private initializeUI(): void {
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement; this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
this.songInput = document.getElementById('songInput') as HTMLInputElement; this.songInput = document.getElementById('songInput') as HTMLInputElement;
@@ -93,6 +106,11 @@ class MotifApp {
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement; 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) // Optional embed UI (only present on main page)
this.embedSection = document.getElementById('embedSection'); this.embedSection = document.getElementById('embedSection');
this.embedCodeEl = document.getElementById('embedCode'); this.embedCodeEl = document.getElementById('embedCode');
@@ -114,7 +132,7 @@ class MotifApp {
this.soundfontStopBtn.addEventListener('click', () => this.handleSoundfontStop()); this.soundfontStopBtn.addEventListener('click', () => this.handleSoundfontStop());
this.soundfontVolumeSlider.addEventListener('input', (e) => { this.soundfontVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value); const volume = parseFloat((e.target as HTMLInputElement).value);
this.soundfontPlayer.setVolume(volume); this.soundfontPlayer?.setVolume(volume);
}); });
// Motif // Motif
@@ -136,6 +154,70 @@ class MotifApp {
// Embed snippet copy (may be disabled / not-live) // Embed snippet copy (may be disabled / not-live)
this.copyEmbedBtn?.addEventListener('click', () => void this.copyEmbedSnippet()); 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> { private async handleSearch(): Promise<void> {
@@ -171,6 +253,7 @@ class MotifApp {
this.displayResults(); this.displayResults();
this.updateStatus(`Found ${results.length} MIDI files. Select one to play.`); this.updateStatus(`Found ${results.length} MIDI files. Select one to play.`);
this.updateIOSAudioBanner();
} catch (error) { } catch (error) {
this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown 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 } }; this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
// Load into preview player // Load into preview player (ensure audio unlocked on iOS)
await this.soundfontPlayer.load(events); const player = await this.ensureAudioReady();
await player.load(events);
// Update UI // Update UI
this.selectedTitle.textContent = result.title; this.selectedTitle.textContent = result.title;
@@ -259,6 +343,7 @@ class MotifApp {
`; `;
this.updateEmbedSnippet(result.title); this.updateEmbedSnippet(result.title);
this.updateIOSAudioBanner();
this.playerSection.classList.add('visible'); this.playerSection.classList.add('visible');
this.enablePlayerControls(); this.enablePlayerControls();
@@ -273,17 +358,20 @@ class MotifApp {
private async handleSoundfontPlay(): Promise<void> { private async handleSoundfontPlay(): Promise<void> {
if (!this.currentMIDI) return; if (!this.currentMIDI) return;
try { 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.soundfontPlayBtn.disabled = true;
this.soundfontStopBtn.disabled = false; this.soundfontStopBtn.disabled = false;
this.updateStatus('Previewing MIDI...'); this.updateStatus('Previewing MIDI...');
} catch (error) { } catch (error) {
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`); this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
this.updateIOSAudioBanner();
} }
private handleSoundfontStop(): void { private handleSoundfontStop(): void {
this.soundfontPlayer.stop(); this.soundfontPlayer?.stop();
this.soundfontPlayBtn.disabled = false; this.soundfontPlayBtn.disabled = false;
this.soundfontStopBtn.disabled = true; this.soundfontStopBtn.disabled = true;
this.updateStatus('Preview stopped.'); this.updateStatus('Preview stopped.');
@@ -298,6 +386,10 @@ class MotifApp {
} }
try { try {
// Best-effort: ensure iOS audio is unlocked from this user gesture.
await unlockAudio();
this.updateIOSAudioBanner();
this.updateStatus('Generating Motif synthesis...'); this.updateStatus('Generating Motif synthesis...');
this.motifBtn.disabled = true; this.motifBtn.disabled = true;