ym2149_wasm/
lib.rs

1//! WebAssembly bindings for YM2149 PSG emulator.
2//!
3//! This crate provides WebAssembly bindings for playing YM2149 chiptune files
4//! directly in web browsers using the Web Audio API.
5//!
6//! # Features
7//!
8//! - Load and play YM2-YM6 format files
9//! - Load and play Arkos Tracker (.aks) files
10//! - Load and play AY format files
11//! - Playback control (play, pause, stop, seek)
12//! - Volume control
13//! - Metadata extraction (title, author, comments)
14//! - Channel muting/solo
15//! - Real-time waveform data for visualization
16//!
17//! # Example Usage (JavaScript)
18//!
19//! ```javascript
20//! import init, { Ym2149Player } from './ym2149_wasm.js';
21//!
22//! async function playYmFile(fileData) {
23//!     await init();
24//!
25//!     const player = Ym2149Player.new(fileData);
26//!     const metadata = player.get_metadata();
27//!     console.log(`Playing: ${metadata.title} by ${metadata.author}`);
28//!
29//!     player.play();
30//! }
31//! ```
32//!
33//! # Module Organization
34//!
35//! Internal modules handle:
36//!
37//! - Metadata types and conversion functions
38//! - Player wrappers for different file formats
39
40#![warn(missing_docs)]
41
42mod metadata;
43mod players;
44
45use wasm_bindgen::prelude::*;
46use ym2149_arkos_replayer::{ArkosPlayer, load_aks};
47use ym2149_ay_replayer::{AyPlayer, CPC_UNSUPPORTED_MSG};
48use ym2149_sndh_replayer::is_sndh_data;
49use ym2149_ym_replayer::{PlaybackState, load_song};
50
51use metadata::{YmMetadata, metadata_from_summary};
52use players::{BrowserSongPlayer, arkos::ArkosWasmPlayer, ay::AyWasmPlayer, sndh::SndhWasmPlayer};
53use ym2149_common::DEFAULT_SAMPLE_RATE;
54
55/// Sample rate used for audio generation.
56pub const YM_SAMPLE_RATE_F32: f32 = DEFAULT_SAMPLE_RATE as f32;
57
58/// Set panic hook for better error messages in the browser console.
59#[wasm_bindgen(start)]
60pub fn init_panic_hook() {
61    console_error_panic_hook::set_once();
62}
63
64/// Log to browser console.
65macro_rules! console_log {
66    ($($t:tt)*) => {
67        web_sys::console::log_1(&format!($($t)*).into());
68    }
69}
70
71/// Main YM2149 player for WebAssembly.
72///
73/// This player handles YM/AKS/AY file playback in the browser, generating audio samples
74/// that can be fed into the Web Audio API.
75#[wasm_bindgen]
76pub struct Ym2149Player {
77    player: BrowserSongPlayer,
78    metadata: YmMetadata,
79    volume: f32,
80}
81
82#[wasm_bindgen]
83impl Ym2149Player {
84    /// Create a new player from file data.
85    ///
86    /// Automatically detects the file format (YM, AKS, AY, or SNDH).
87    ///
88    /// # Arguments
89    ///
90    /// * `data` - File data as Uint8Array
91    ///
92    /// # Returns
93    ///
94    /// Result containing the player or an error message.
95    #[wasm_bindgen(constructor)]
96    pub fn new(data: &[u8]) -> Result<Ym2149Player, JsValue> {
97        console_log!("Loading file ({} bytes)...", data.len());
98
99        let (player, metadata) = load_browser_player(data).map_err(|e| {
100            JsValue::from_str(&format!(
101                "Failed to load chiptune file ({} bytes): {}",
102                data.len(),
103                e
104            ))
105        })?;
106
107        console_log!("Song loaded successfully");
108        console_log!("  Title: {}", metadata.title);
109        console_log!("  Format: {}", metadata.format);
110
111        Ok(Ym2149Player {
112            player,
113            metadata,
114            volume: 1.0,
115        })
116    }
117
118    /// Get metadata about the loaded file.
119    #[wasm_bindgen(getter)]
120    pub fn metadata(&self) -> YmMetadata {
121        self.metadata.clone()
122    }
123
124    /// Start playback.
125    pub fn play(&mut self) {
126        self.player.play();
127    }
128
129    /// Pause playback.
130    pub fn pause(&mut self) {
131        self.player.pause();
132    }
133
134    /// Stop playback and reset to beginning.
135    pub fn stop(&mut self) {
136        self.player.stop();
137    }
138
139    /// Restart playback from the beginning.
140    pub fn restart(&mut self) {
141        self.player.stop();
142        self.player.play();
143    }
144
145    /// Get current playback state.
146    pub fn is_playing(&self) -> bool {
147        self.player.state() == PlaybackState::Playing
148    }
149
150    /// Get current playback state as string.
151    pub fn state(&self) -> String {
152        format!("{:?}", self.player.state())
153    }
154
155    /// Set volume (0.0 to 1.0). Applied to generated samples.
156    pub fn set_volume(&mut self, volume: f32) {
157        self.volume = volume.clamp(0.0, 1.0);
158    }
159
160    /// Get current volume (0.0 to 1.0).
161    pub fn volume(&self) -> f32 {
162        self.volume
163    }
164
165    /// Get current frame position.
166    pub fn frame_position(&self) -> u32 {
167        self.player.frame_position() as u32
168    }
169
170    /// Get total frame count.
171    pub fn frame_count(&self) -> u32 {
172        self.player.frame_count() as u32
173    }
174
175    /// Get playback position as percentage (0.0 to 1.0).
176    pub fn position_percentage(&self) -> f32 {
177        self.player.playback_position()
178    }
179
180    /// Seek to a specific frame (silently ignored for Arkos/AY backends).
181    pub fn seek_to_frame(&mut self, frame: u32) {
182        let _ = self.player.seek_frame(frame as usize);
183    }
184
185    /// Seek to a percentage of the song (0.0 to 1.0, silently ignored for Arkos/AY backends).
186    pub fn seek_to_percentage(&mut self, percentage: f32) {
187        let total_frames = self.player.frame_count().max(1);
188        let clamped = percentage.clamp(0.0, 1.0);
189        let target = ((total_frames as f32 - 1.0) * clamped).round() as usize;
190        let _ = self.player.seek_frame(target);
191    }
192
193    /// Mute or unmute a channel (0-2).
194    pub fn set_channel_mute(&mut self, channel: usize, mute: bool) {
195        self.player.set_channel_mute(channel, mute);
196    }
197
198    /// Check if a channel is muted.
199    pub fn is_channel_muted(&self, channel: usize) -> bool {
200        self.player.is_channel_muted(channel)
201    }
202
203    /// Generate audio samples.
204    ///
205    /// Returns a Float32Array containing mono samples.
206    /// The number of samples generated depends on the sample rate and frame rate.
207    ///
208    /// For 44.1kHz at 50Hz frame rate: 882 samples per frame.
209    #[wasm_bindgen(js_name = generateSamples)]
210    pub fn generate_samples(&mut self, count: usize) -> Vec<f32> {
211        let mut samples = self.player.generate_samples(count);
212        if self.volume != 1.0 {
213            for sample in &mut samples {
214                *sample *= self.volume;
215            }
216        }
217        samples
218    }
219
220    /// Generate samples into a pre-allocated buffer (zero-allocation).
221    ///
222    /// This is more efficient than `generate_samples` as it reuses the same buffer.
223    #[wasm_bindgen(js_name = generateSamplesInto)]
224    pub fn generate_samples_into(&mut self, buffer: &mut [f32]) {
225        self.player.generate_samples_into(buffer);
226        if self.volume != 1.0 {
227            for sample in buffer.iter_mut() {
228                *sample *= self.volume;
229            }
230        }
231    }
232
233    /// Get the current register values (for visualization).
234    pub fn get_registers(&self) -> Vec<u8> {
235        self.player.dump_registers().to_vec()
236    }
237
238    /// Get channel states for visualization (frequency, amplitude, note, effects).
239    ///
240    /// Returns a JsValue containing an object with channel data:
241    /// ```json
242    /// {
243    ///   "channels": [
244    ///     { "frequency": 440.0, "note": "A4", "amplitude": 0.8, "toneEnabled": true, "noiseEnabled": false, "envelopeEnabled": false },
245    ///     ...
246    ///   ],
247    ///   "envelope": { "period": 256, "shape": 14, "shapeName": "/\\/\\" }
248    /// }
249    /// ```
250    #[wasm_bindgen(js_name = getChannelStates)]
251    pub fn get_channel_states(&self) -> JsValue {
252        use ym2149_common::ChannelStates;
253
254        let regs = self.player.dump_registers();
255        let states = ChannelStates::from_registers(&regs);
256
257        // Build JavaScript-friendly object
258        let obj = js_sys::Object::new();
259
260        // Channels array
261        let channels = js_sys::Array::new();
262        for ch in &states.channels {
263            let ch_obj = js_sys::Object::new();
264            js_sys::Reflect::set(
265                &ch_obj,
266                &"frequency".into(),
267                &ch.frequency_hz.unwrap_or(0.0).into(),
268            )
269            .ok();
270            js_sys::Reflect::set(
271                &ch_obj,
272                &"note".into(),
273                &ch.note_name.unwrap_or("--").into(),
274            )
275            .ok();
276            js_sys::Reflect::set(
277                &ch_obj,
278                &"amplitude".into(),
279                &ch.amplitude_normalized.into(),
280            )
281            .ok();
282            js_sys::Reflect::set(&ch_obj, &"toneEnabled".into(), &ch.tone_enabled.into()).ok();
283            js_sys::Reflect::set(&ch_obj, &"noiseEnabled".into(), &ch.noise_enabled.into()).ok();
284            js_sys::Reflect::set(
285                &ch_obj,
286                &"envelopeEnabled".into(),
287                &ch.envelope_enabled.into(),
288            )
289            .ok();
290            channels.push(&ch_obj);
291        }
292        js_sys::Reflect::set(&obj, &"channels".into(), &channels).ok();
293
294        // Envelope info
295        let env_obj = js_sys::Object::new();
296        js_sys::Reflect::set(&env_obj, &"period".into(), &states.envelope.period.into()).ok();
297        js_sys::Reflect::set(&env_obj, &"shape".into(), &states.envelope.shape.into()).ok();
298        js_sys::Reflect::set(
299            &env_obj,
300            &"shapeName".into(),
301            &states.envelope.shape_name.into(),
302        )
303        .ok();
304        js_sys::Reflect::set(&obj, &"envelope".into(), &env_obj).ok();
305
306        obj.into()
307    }
308
309    /// Enable or disable the ST color filter.
310    pub fn set_color_filter(&mut self, enabled: bool) {
311        self.player.set_color_filter(enabled);
312    }
313
314    /// Get the number of subsongs (1 for most formats, >1 for multi-song SNDH files).
315    #[wasm_bindgen(js_name = subsongCount)]
316    pub fn subsong_count(&self) -> usize {
317        self.player.subsong_count()
318    }
319
320    /// Get the current subsong index (1-based).
321    #[wasm_bindgen(js_name = currentSubsong)]
322    pub fn current_subsong(&self) -> usize {
323        self.player.current_subsong()
324    }
325
326    /// Set the current subsong (1-based index). Returns true on success.
327    #[wasm_bindgen(js_name = setSubsong)]
328    pub fn set_subsong(&mut self, index: usize) -> bool {
329        self.player.set_subsong(index)
330    }
331}
332
333/// Load a file and create the appropriate player.
334fn load_browser_player(data: &[u8]) -> Result<(BrowserSongPlayer, YmMetadata), String> {
335    if data.is_empty() {
336        return Err("empty file data".to_string());
337    }
338
339    // SNDH needs to be detected first to avoid falling back to AY/other formats
340    // when the header already looks like a packed SNDH.
341    if is_sndh_data(data) {
342        let (wrapper, metadata) = SndhWasmPlayer::new(data)?;
343        return Ok((BrowserSongPlayer::Sndh(Box::new(wrapper)), metadata));
344    }
345
346    // Try YM format first
347    if let Ok((player, summary)) = load_song(data) {
348        let metadata = metadata_from_summary(&player, &summary);
349        return Ok((BrowserSongPlayer::Ym(Box::new(player)), metadata));
350    }
351
352    // Try Arkos format
353    if let Ok(song) = load_aks(data) {
354        let arkos_player =
355            ArkosPlayer::new(song, 0).map_err(|e| format!("Arkos player init failed: {e}"))?;
356        let (wrapper, metadata) = ArkosWasmPlayer::new(arkos_player);
357        return Ok((BrowserSongPlayer::Arkos(Box::new(wrapper)), metadata));
358    }
359
360    // Try SNDH format (Atari ST) even if the heuristic didn't match
361    if let Ok((wrapper, metadata)) = SndhWasmPlayer::new(data) {
362        return Ok((BrowserSongPlayer::Sndh(Box::new(wrapper)), metadata));
363    }
364
365    // Try AY format as last resort
366    let (player, meta) = AyPlayer::load_from_bytes(data, 0)
367        .map_err(|e| format!("unrecognized format (AY parse error: {e})"))?;
368    if player.requires_cpc_firmware() {
369        return Err(CPC_UNSUPPORTED_MSG.to_string());
370    }
371    let (wrapper, metadata) = AyWasmPlayer::new(player, &meta);
372    Ok((BrowserSongPlayer::Ay(Box::new(wrapper)), metadata))
373}
374
375// Re-export for wasm-pack
376#[wasm_bindgen]
377extern "C" {
378    #[wasm_bindgen(js_namespace = console)]
379    fn log(s: &str);
380}