diff --git a/apps/desktop/core/src/analysis_process_status.rs b/apps/desktop/core/src/analysis_process_status.rs new file mode 100644 index 000000000..beb0935fd --- /dev/null +++ b/apps/desktop/core/src/analysis_process_status.rs @@ -0,0 +1,164 @@ +//! Parse analysis-process status without leaking native artifact metadata. +//! +//! Python analysis may emit a path-free playable-stem artifact reference for the +//! trusted native process. The renderer status must not carry that reference's +//! hashes, sizes, or storage-derived identity. This module removes and validates +//! the optional native-only field before deserializing the existing public status. + +use crate::{ + playable_stem_contract::PlayableStemArtifactSetReference, AnalysisJobState, + AnalysisJobStatus, +}; +use serde_json::Value; + +const PROCESS_STATUS_ERROR: &str = "Analysis engine returned an invalid response."; + +/// One validated analysis-process status split into public and native-only data. +#[derive(Clone, Debug)] +pub struct AnalysisProcessStatus { + renderer_status: AnalysisJobStatus, + playable_stem_artifact_set: Option, +} + +impl AnalysisProcessStatus { + /// Return the renderer-safe status after native-only metadata was removed. + pub const fn renderer_status(&self) -> &AnalysisJobStatus { + &self.renderer_status + } + + /// Return the optional native-only playable-stem reference. + pub const fn playable_stem_artifact_set( + &self, + ) -> Option<&PlayableStemArtifactSetReference> { + self.playable_stem_artifact_set.as_ref() + } + + /// Consume the parsed envelope and return its independently owned parts. + pub fn into_parts( + self, + ) -> ( + AnalysisJobStatus, + Option, + ) { + (self.renderer_status, self.playable_stem_artifact_set) + } +} + +/// Replace the retained native process envelope with the newest validated status. +/// +/// Renderer delivery receives a clone containing only public status fields while +/// native callers keep the complete envelope. Replacing the whole envelope is +/// intentional: a later terminal status without a stem reference must revoke an +/// earlier status's native reference instead of leaving stale metadata eligible +/// for playback-authority binding. +pub fn retain_latest_process_status( + latest_process_status: &mut Option, + process_status: AnalysisProcessStatus, +) -> AnalysisJobStatus { + let renderer_status = process_status.renderer_status.clone(); + *latest_process_status = Some(process_status); + renderer_status +} + +/// Require a process envelope to belong to the native job that requested it. +pub fn validate_analysis_process_status_for_job( + process_status: &AnalysisProcessStatus, + expected_job_id: &str, +) -> Result<(), &'static str> { + if process_status.renderer_status.job_id == expected_job_id { + Ok(()) + } else { + Err(PROCESS_STATUS_ERROR) + } +} + +/// Return whether a validated status is safe to expose as in-flight progress. +/// +/// Producer terminal states are withheld until the subprocess exits and the +/// complete JSONL stream is known to be valid. This prevents an early succeeded +/// event from being observed before a later malformed line fails the process. +pub fn is_analysis_process_progress_status(process_status: &AnalysisProcessStatus) -> bool { + matches!( + &process_status.renderer_status.state, + AnalysisJobState::Queued | AnalysisJobState::Running + ) +} + +/// Require the process's final retained envelope to be terminal and job-local. +pub fn validate_final_analysis_process_status<'a>( + process_status: Option<&'a AnalysisProcessStatus>, + expected_job_id: &str, +) -> Result<&'a AnalysisProcessStatus, &'static str> { + let process_status = process_status.ok_or(PROCESS_STATUS_ERROR)?; + validate_analysis_process_status_for_job(process_status, expected_job_id)?; + if matches!( + &process_status.renderer_status.state, + AnalysisJobState::Succeeded | AnalysisJobState::Failed + ) { + Ok(process_status) + } else { + Err(PROCESS_STATUS_ERROR) + } +} + +/// Parse one stdout JSONL line, ignoring only whitespace-only separators. +/// +/// A non-empty malformed line is a process-contract failure. Returning an error +/// instead of skipping it prevents an earlier valid envelope from becoming the +/// apparent final result after corrupted or future producer output. +pub fn parse_analysis_process_status_line( + process_status_line: &str, +) -> Result, &'static str> { + let trimmed = process_status_line.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + parse_analysis_process_status(trimmed).map(Some) + } +} + +/// Parse one JSONL status and isolate its optional native-only artifact reference. +pub fn parse_analysis_process_status( + process_status_json: &str, +) -> Result { + let mut process_status_value = + serde_json::from_str::(process_status_json).map_err(|_| PROCESS_STATUS_ERROR)?; + let process_status_object = process_status_value + .as_object_mut() + .ok_or(PROCESS_STATUS_ERROR)?; + let playable_stem_value = process_status_object.remove("playableStemArtifactSet"); + + let renderer_status = + serde_json::from_value::(process_status_value) + .map_err(|_| PROCESS_STATUS_ERROR)?; + let playable_stem_artifact_set = playable_stem_value + .map(serde_json::from_value::) + .transpose() + .map_err(|_| PROCESS_STATUS_ERROR)?; + + let state_payload_is_valid = match &renderer_status.state { + AnalysisJobState::Succeeded => { + renderer_status.result.is_some() && renderer_status.error.is_none() + } + AnalysisJobState::Failed => { + renderer_status.result.is_none() && renderer_status.error.is_some() + } + AnalysisJobState::Queued | AnalysisJobState::Running => { + renderer_status.result.is_none() && renderer_status.error.is_none() + } + }; + if !state_payload_is_valid { + return Err(PROCESS_STATUS_ERROR); + } + + if playable_stem_artifact_set.is_some() + && !matches!(&renderer_status.state, AnalysisJobState::Succeeded) + { + return Err(PROCESS_STATUS_ERROR); + } + + Ok(AnalysisProcessStatus { + renderer_status, + playable_stem_artifact_set, + }) +} diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..5a8963645 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -6,6 +6,9 @@ //! measured for coverage) on any platform without a windowing system or a //! bundled frontend. +pub mod analysis_process_status; +pub mod playable_stem_contract; + use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::{ @@ -122,12 +125,59 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + tempo: Option, sections: Vec, export_summary: ExportSummaryPayload, #[serde(default, skip_serializing_if = "Option::is_none")] + collaboration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] score_attachments: Option>, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalAssignmentPayload { + id: String, + assignee: String, + summary: String, + section_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + role_id: Option, + status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCommentPayload { + id: String, + author: String, + body: String, + section_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + role_id: Option, + status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalApprovalPayload { + id: String, + scope: String, + owner: String, + status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalCollaborationPayload { + sync_mode: String, + sync_note: String, + assignments: Vec, + comments: Vec, + approvals: Vec, +} + /// Score attachment metadata persisted inside the song payload. Only the /// locally minted score id and the display file name cross the IPC boundary; /// the PDF bytes stay in the app-owned scores directory keyed by that id. @@ -176,6 +226,15 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -183,14 +242,22 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, rehearsal_priority: String, simplification: String, setup_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + transcription: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + practice_progress: Option, } #[derive(Clone, Debug, Serialize)] diff --git a/apps/desktop/core/src/playable_stem_contract.rs b/apps/desktop/core/src/playable_stem_contract.rs new file mode 100644 index 000000000..6442e037a --- /dev/null +++ b/apps/desktop/core/src/playable_stem_contract.rs @@ -0,0 +1,404 @@ +//! Path-free contract for locally generated playable stem artifacts. +//! +//! The analysis process may publish aligned WAV files below an app-owned +//! project temporary root, but it cannot grant filesystem authority by returning +//! a path. This module validates the path-free metadata used by the native +//! process to derive and verify the only permitted file locations. + +use serde::{Deserialize, Deserializer, Serialize}; +use std::path::{Path, PathBuf}; + +/// Version of the playable-stem artifact and metadata contract. +pub const PLAYABLE_STEM_ARTIFACT_VERSION: u8 = 1; + +/// Smallest accepted sample rate for generated playback artifacts. +pub const MIN_PLAYBACK_SAMPLE_RATE_HZ: u32 = 8_000; + +/// Largest accepted sample rate for generated playback artifacts. +pub const MAX_PLAYBACK_SAMPLE_RATE_HZ: u32 = 192_000; + +const PCM16_BYTES_PER_SAMPLE: u64 = 2; +const CANONICAL_WAVE_HEADER_BYTES: u64 = 44; +const RIFF_CHUNK_PREFIX_BYTES: u64 = 8; +const RIFF_CHUNK_OVERHEAD_BYTES: u64 = + CANONICAL_WAVE_HEADER_BYTES - RIFF_CHUNK_PREFIX_BYTES; +const SHA256_HEX_CHARACTER_COUNT: usize = 64; +const DURATION_RELATIVE_TOLERANCE: f64 = 1e-12; + +/// Largest mono PCM16 sample count representable by a classic RIFF/WAV header. +pub const MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT: u64 = + (u32::MAX as u64 - RIFF_CHUNK_OVERHEAD_BYTES) / PCM16_BYTES_PER_SAMPLE; + +/// Canonical source kinds produced by the current BandScope separation model. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlaybackStemKind { + /// Isolated vocal source. + Vocals, + /// Isolated bass source. + Bass, + /// Isolated drum source. + Drums, + /// Remaining instruments not identified as a more specific source. + Other, +} + +impl PlaybackStemKind { + /// Return all supported sources in their canonical wire order. + pub const fn canonical_order() -> [Self; 4] { + [Self::Vocals, Self::Bass, Self::Drums, Self::Other] + } + + /// Return the exact artifact identifier owned by this source kind. + pub const fn artifact_id(self) -> &'static str { + match self { + Self::Vocals => "stem-vocals", + Self::Bass => "stem-bass", + Self::Drums => "stem-drums", + Self::Other => "stem-other", + } + } + + /// Return the fixed WAV filename owned by this source kind. + pub const fn file_name(self) -> &'static str { + match self { + Self::Vocals => "vocals.wav", + Self::Bass => "bass.wav", + Self::Drums => "drums.wav", + Self::Other => "other.wav", + } + } +} + +/// Path-free metadata for one generated source file. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayableStemArtifactReference { + artifact_id: String, + stem_kind: PlaybackStemKind, + file_size_bytes: u64, + content_hash_sha256: String, + media_type: String, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, +} + +impl PlayableStemArtifactReference { + /// Return the canonical artifact identifier. + pub fn artifact_id(&self) -> &str { + &self.artifact_id + } + + /// Return the canonical source kind. + pub const fn stem_kind(&self) -> PlaybackStemKind { + self.stem_kind + } + + /// Return the expected on-disk byte size. + pub const fn file_size_bytes(&self) -> u64 { + self.file_size_bytes + } + + /// Return the lowercase SHA-256 digest of the complete WAV file. + pub fn content_hash_sha256(&self) -> &str { + &self.content_hash_sha256 + } + + /// Return the exact media type for the generated artifact. + pub fn media_type(&self) -> &str { + &self.media_type + } + + /// Return the expected sample rate. + pub const fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Return the expected channel count. + pub const fn channel_count(&self) -> u8 { + self.channel_count + } + + /// Return the expected number of mono PCM samples. + pub const fn sample_count(&self) -> u64 { + self.sample_count + } + + /// Return the expected media duration. + pub const fn duration_seconds(&self) -> f64 { + self.duration_seconds + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawPlayableStemArtifactReference { + artifact_id: String, + stem_kind: PlaybackStemKind, + file_size_bytes: u64, + content_hash_sha256: String, + media_type: String, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, +} + +/// Path-free metadata for one complete and aligned generated source set. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayableStemArtifactSetReference { + artifact_set_id: String, + format_version: u8, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, + applied_gain: f64, + stem_artifacts: Vec, +} + +impl PlayableStemArtifactSetReference { + /// Return the lowercase SHA-256 identity used as the fixed directory name. + pub fn artifact_set_id(&self) -> &str { + &self.artifact_set_id + } + + /// Return the artifact contract version. + pub const fn format_version(&self) -> u8 { + self.format_version + } + + /// Return the common sample rate for every source. + pub const fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Return the common channel count for every source. + pub const fn channel_count(&self) -> u8 { + self.channel_count + } + + /// Return the common sample count for every source. + pub const fn sample_count(&self) -> u64 { + self.sample_count + } + + /// Return the common duration for every source. + pub const fn duration_seconds(&self) -> f64 { + self.duration_seconds + } + + /// Return the set-wide gain applied before PCM16 encoding. + pub const fn applied_gain(&self) -> f64 { + self.applied_gain + } + + /// Return the four canonical artifact references in wire order. + pub fn stem_artifacts(&self) -> &[PlayableStemArtifactReference] { + &self.stem_artifacts + } + + /// Derive the only permitted path for one artifact from a native-owned root. + pub fn derive_artifact_path( + &self, + project_temp_root: &Path, + stem_kind: PlaybackStemKind, + ) -> PathBuf { + project_temp_root + .join("playable-stems-v1") + .join(&self.artifact_set_id) + .join(stem_kind.file_name()) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawPlayableStemArtifactSetReference { + artifact_set_id: String, + format_version: u8, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, + applied_gain: f64, + stem_artifacts: Vec, +} + +impl<'de> Deserialize<'de> for PlayableStemArtifactSetReference { + fn deserialize( + artifact_deserializer: ArtifactDeserializer, + ) -> Result + where + ArtifactDeserializer: Deserializer<'de>, + { + let raw_artifact_set = + RawPlayableStemArtifactSetReference::deserialize(artifact_deserializer)?; + validate_sha256_hex(&raw_artifact_set.artifact_set_id, "artifactSetId") + .map_err(serde::de::Error::custom)?; + if raw_artifact_set.format_version != PLAYABLE_STEM_ARTIFACT_VERSION { + return Err(serde::de::Error::custom( + "unsupported playable stem artifact formatVersion", + )); + } + if !(MIN_PLAYBACK_SAMPLE_RATE_HZ..=MAX_PLAYBACK_SAMPLE_RATE_HZ) + .contains(&raw_artifact_set.sample_rate) + { + return Err(serde::de::Error::custom( + "playable stem sampleRate is outside the supported range", + )); + } + if raw_artifact_set.channel_count != 1 { + return Err(serde::de::Error::custom( + "playable stem channelCount must be one", + )); + } + if raw_artifact_set.sample_count == 0 { + return Err(serde::de::Error::custom( + "playable stem sampleCount must be positive", + )); + } + if raw_artifact_set.sample_count > MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT { + return Err(serde::de::Error::custom( + "playable stem sampleCount exceeds the classic RIFF/WAV limit", + )); + } + let expected_duration = + raw_artifact_set.sample_count as f64 / raw_artifact_set.sample_rate as f64; + validate_duration(raw_artifact_set.duration_seconds, expected_duration) + .map_err(serde::de::Error::custom)?; + validate_applied_gain(raw_artifact_set.applied_gain) + .map_err(serde::de::Error::custom)?; + if raw_artifact_set.stem_artifacts.len() != PlaybackStemKind::canonical_order().len() { + return Err(serde::de::Error::custom( + "playable stem artifact set must contain exactly four sources", + )); + } + + let expected_file_size = CANONICAL_WAVE_HEADER_BYTES + + raw_artifact_set.sample_count * PCM16_BYTES_PER_SAMPLE; + let mut stem_artifacts = Vec::with_capacity(raw_artifact_set.stem_artifacts.len()); + for (raw_artifact, expected_stem_kind) in raw_artifact_set + .stem_artifacts + .into_iter() + .zip(PlaybackStemKind::canonical_order()) + { + validate_artifact( + &raw_artifact, + expected_stem_kind, + raw_artifact_set.sample_rate, + raw_artifact_set.channel_count, + raw_artifact_set.sample_count, + raw_artifact_set.duration_seconds, + expected_file_size, + ) + .map_err(serde::de::Error::custom)?; + stem_artifacts.push(PlayableStemArtifactReference { + artifact_id: raw_artifact.artifact_id, + stem_kind: raw_artifact.stem_kind, + file_size_bytes: raw_artifact.file_size_bytes, + content_hash_sha256: raw_artifact.content_hash_sha256, + media_type: raw_artifact.media_type, + sample_rate: raw_artifact.sample_rate, + channel_count: raw_artifact.channel_count, + sample_count: raw_artifact.sample_count, + duration_seconds: raw_artifact.duration_seconds, + }); + } + + Ok(Self { + artifact_set_id: raw_artifact_set.artifact_set_id, + format_version: raw_artifact_set.format_version, + sample_rate: raw_artifact_set.sample_rate, + channel_count: raw_artifact_set.channel_count, + sample_count: raw_artifact_set.sample_count, + duration_seconds: raw_artifact_set.duration_seconds, + applied_gain: raw_artifact_set.applied_gain, + stem_artifacts, + }) + } +} + +fn validate_artifact( + raw_artifact: &RawPlayableStemArtifactReference, + expected_stem_kind: PlaybackStemKind, + sample_rate: u32, + channel_count: u8, + sample_count: u64, + duration_seconds: f64, + expected_file_size: u64, +) -> Result<(), String> { + if raw_artifact.stem_kind != expected_stem_kind { + return Err("playable stems must use canonical source order".to_string()); + } + if raw_artifact.artifact_id != expected_stem_kind.artifact_id() { + return Err("playable stem artifactId does not match stemKind".to_string()); + } + validate_sha256_hex(&raw_artifact.content_hash_sha256, "contentHashSha256")?; + if raw_artifact.media_type != "audio/wav" { + return Err("playable stem mediaType must be audio/wav".to_string()); + } + if raw_artifact.sample_rate != sample_rate + || raw_artifact.channel_count != channel_count + || raw_artifact.sample_count != sample_count + { + return Err("playable stem media metadata is not aligned with its set".to_string()); + } + validate_duration(raw_artifact.duration_seconds, duration_seconds)?; + if raw_artifact.file_size_bytes != expected_file_size { + return Err("playable stem fileSizeBytes does not match canonical PCM16 WAV".to_string()); + } + Ok(()) +} + +fn validate_sha256_hex(hash_value: &str, field_name: &str) -> Result<(), String> { + if hash_value.len() != SHA256_HEX_CHARACTER_COUNT + || !hash_value.bytes().all(|hex_character| { + hex_character.is_ascii_digit() || (b'a'..=b'f').contains(&hex_character) + }) + { + return Err(format!( + "playable stem {field_name} must be lowercase SHA-256 hex" + )); + } + Ok(()) +} + +fn validate_duration(actual_duration: f64, expected_duration: f64) -> Result<(), String> { + let duration_tolerance = + expected_duration.abs().max(1.0) * DURATION_RELATIVE_TOLERANCE; + if !actual_duration.is_finite() + || actual_duration <= 0.0 + || (actual_duration - expected_duration).abs() > duration_tolerance + { + return Err("playable stem durationSeconds is inconsistent".to_string()); + } + Ok(()) +} + +fn validate_applied_gain(applied_gain: f64) -> Result<(), String> { + if !applied_gain.is_finite() || applied_gain <= 0.0 || applied_gain > 1.0 { + return Err("playable stem appliedGain must be finite and within (0, 1]".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod contract_unit_tests { + use super::{validate_applied_gain, validate_duration, validate_sha256_hex}; + + #[test] + fn internal_numeric_guards_reject_nonfinite_values() { + assert!(validate_duration(f64::NAN, 1.0).is_err()); + assert!(validate_duration(f64::INFINITY, 1.0).is_err()); + assert!(validate_applied_gain(f64::NAN).is_err()); + assert!(validate_applied_gain(f64::INFINITY).is_err()); + } + + #[test] + fn sha256_guard_accepts_numeric_lowercase_hex() { + assert!(validate_sha256_hex(&"0".repeat(64), "testHash").is_ok()); + } +} diff --git a/apps/desktop/core/tests/analysis_process_contract.rs b/apps/desktop/core/tests/analysis_process_contract.rs new file mode 100644 index 000000000..1ed52b015 --- /dev/null +++ b/apps/desktop/core/tests/analysis_process_contract.rs @@ -0,0 +1,164 @@ +//! Process-contract invariants for the native analysis JSONL boundary. + +use bandscope_desktop_core::{ + analysis_process_status::{ + is_analysis_process_progress_status, parse_analysis_process_status, + validate_analysis_process_status_for_job, validate_final_analysis_process_status, + AnalysisProcessStatus, + }, + AnalysisJobState, +}; +use serde_json::{json, Value}; + +const PROCESS_STATUS_ERROR: &str = "Analysis engine returned an invalid response."; +const JOB_ID: &str = "job-process-contract"; + +fn status(state: &str) -> Value { + let mut value = json!({ + "jobId": JOB_ID, + "state": state, + "requestedAt": "2026-09-04T00:00:00Z", + "updatedAt": "2026-09-04T00:00:01Z" + }); + let object = value + .as_object_mut() + .expect("analysis status fixture must remain an object"); + match state { + "succeeded" => { + object.insert("progressStage".into(), json!("ready")); + object.insert("progressPercent".into(), json!(100)); + object.insert( + "result".into(), + json!({ + "id": "rights-cleared-song", + "title": "Rights-cleared fixture", + "sections": [], + "exportSummary": { + "format": "cue-sheet", + "headline": "Check the first section.", + "focusSections": [] + } + }), + ); + } + "failed" => { + object.insert( + "error".into(), + json!({ + "code": "engine_unavailable", + "message": "Analysis failed." + }), + ); + } + _ => {} + } + value +} + +fn parse(value: Value) -> Result { + parse_analysis_process_status( + &serde_json::to_string(&value).expect("analysis status fixture should serialize"), + ) +} + +#[test] +fn process_status_job_identity_must_match_the_requested_job() { + let process_status = parse(status("succeeded")).expect("valid succeeded status should parse"); + + validate_analysis_process_status_for_job(&process_status, JOB_ID) + .expect("matching process job identity should be accepted"); + let error = validate_analysis_process_status_for_job(&process_status, "job-other") + .expect_err("mismatched process job identity must fail closed"); + assert_eq!(error, PROCESS_STATUS_ERROR); +} + +#[test] +fn final_process_status_must_be_terminal() { + for state in ["queued", "running"] { + let process_status = parse(status(state)).expect("nonterminal status shape should parse"); + let error = validate_final_analysis_process_status(Some(&process_status), JOB_ID) + .expect_err("process exit with nonterminal status must fail closed"); + assert_eq!(error, PROCESS_STATUS_ERROR); + } + + let succeeded = parse(status("succeeded")).expect("succeeded status should parse"); + assert!(matches!( + &validate_final_analysis_process_status(Some(&succeeded), JOB_ID) + .expect("succeeded status is terminal") + .renderer_status() + .state, + AnalysisJobState::Succeeded + )); + + let failed = parse(status("failed")).expect("failed status should parse"); + assert!(matches!( + &validate_final_analysis_process_status(Some(&failed), JOB_ID) + .expect("failed status is terminal") + .renderer_status() + .state, + AnalysisJobState::Failed + )); + + let missing = validate_final_analysis_process_status(None, JOB_ID) + .expect_err("successful process exit without a status must fail closed"); + assert_eq!(missing, PROCESS_STATUS_ERROR); +} + +#[test] +fn only_nonterminal_status_is_renderer_progress() { + for state in ["queued", "running"] { + let process_status = parse(status(state)).expect("progress status should parse"); + assert!(is_analysis_process_progress_status(&process_status)); + } + + for state in ["succeeded", "failed"] { + let process_status = parse(status(state)).expect("terminal status should parse"); + assert!(!is_analysis_process_progress_status(&process_status)); + } +} + +#[test] +fn rejects_contradictory_state_payloads_without_stem_metadata() { + let mut succeeded_without_result = status("succeeded"); + succeeded_without_result + .as_object_mut() + .expect("status fixture must remain an object") + .remove("result"); + + let mut succeeded_with_error = status("succeeded"); + succeeded_with_error + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "error".into(), + json!({"code": "engine_unavailable", "message": "Contradictory status."}), + ); + + let mut failed_without_error = status("failed"); + failed_without_error + .as_object_mut() + .expect("status fixture must remain an object") + .remove("error"); + + let mut queued_with_result = status("queued"); + queued_with_result + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "result".into(), + status("succeeded") + .get("result") + .expect("succeeded fixture should include result") + .clone(), + ); + + for invalid in [ + succeeded_without_result, + succeeded_with_error, + failed_without_error, + queued_with_result, + ] { + let error = parse(invalid).expect_err("contradictory state payload must fail closed"); + assert_eq!(error, PROCESS_STATUS_ERROR); + } +} diff --git a/apps/desktop/core/tests/analysis_process_status.rs b/apps/desktop/core/tests/analysis_process_status.rs new file mode 100644 index 000000000..dcb6caad2 --- /dev/null +++ b/apps/desktop/core/tests/analysis_process_status.rs @@ -0,0 +1,341 @@ +//! Process-boundary tests for native-only playable-stem status metadata. + +use bandscope_desktop_core::{ + analysis_process_status::{ + parse_analysis_process_status, parse_analysis_process_status_line, + retain_latest_process_status, AnalysisProcessStatus, + }, + AnalysisJobState, +}; +use serde_json::{json, Value}; + +const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const CONTENT_HASH: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const PROCESS_STATUS_ERROR: &str = "Analysis engine returned an invalid response."; + +fn playable_stem_artifact_set() -> Value { + json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008, + "appliedGain": 1.0, + "stemArtifacts": [ + playable_stem_artifact("vocals"), + playable_stem_artifact("bass"), + playable_stem_artifact("drums"), + playable_stem_artifact("other") + ] + }) +} + +fn playable_stem_artifact(stem_kind: &str) -> Value { + json!({ + "artifactId": format!("stem-{stem_kind}"), + "stemKind": stem_kind, + "fileSizeBytes": 172, + "contentHashSha256": CONTENT_HASH, + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }) +} + +fn succeeded_status() -> Value { + json!({ + "jobId": "job-playable-stems", + "state": "succeeded", + "requestedAt": "2026-09-04T00:00:00Z", + "updatedAt": "2026-09-04T00:00:01Z", + "progressLabel": "Analysis ready", + "progressStage": "ready", + "progressPercent": 100, + "cacheStatus": "stored", + "result": { + "id": "rights-cleared-song", + "title": "Rights-cleared fixture", + "sections": [], + "exportSummary": { + "format": "cue-sheet", + "headline": "Check the first section.", + "focusSections": [] + } + } + }) +} + +fn queued_status() -> Value { + json!({ + "jobId": "job-queued", + "state": "queued", + "requestedAt": "2026-09-04T00:00:00Z", + "updatedAt": "2026-09-04T00:00:00Z", + "progressLabel": "Queued for analysis", + "progressStage": "queued", + "progressPercent": 0, + "cacheStatus": "disabled" + }) +} + +fn parse_status_value( + process_status_value: Value, +) -> Result { + parse_analysis_process_status( + &serde_json::to_string(&process_status_value) + .expect("process status fixture should serialize"), + ) +} + +fn assert_invalid_status(process_status_value: Value) { + let process_error = parse_status_value(process_status_value) + .expect_err("malformed process status must fail closed"); + assert_eq!(process_error, PROCESS_STATUS_ERROR); +} + +fn assert_invalid_json(process_status_json: &str) { + let process_error = parse_analysis_process_status(process_status_json) + .expect_err("malformed JSONL status must fail closed"); + assert_eq!(process_error, PROCESS_STATUS_ERROR); +} + +#[test] +fn isolates_native_artifact_reference_from_renderer_status() { + let mut process_status_value = succeeded_status(); + process_status_value + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + let process_status = + parse_status_value(process_status_value).expect("complete process status should parse"); + assert_eq!( + process_status.renderer_status().job_id, + "job-playable-stems" + ); + assert!(matches!( + &process_status.renderer_status().state, + AnalysisJobState::Succeeded + )); + assert_eq!( + process_status + .playable_stem_artifact_set() + .expect("native artifact reference should be retained") + .artifact_set_id(), + ARTIFACT_SET_ID + ); + + let renderer_status_json = serde_json::to_string(process_status.renderer_status()) + .expect("renderer status should serialize"); + assert!(!renderer_status_json.contains("playableStemArtifactSet")); + assert!(!renderer_status_json.contains(CONTENT_HASH)); + assert!(!renderer_status_json.to_ascii_lowercase().contains("path")); + + let (renderer_status, playable_stem_artifact_set) = process_status.into_parts(); + assert_eq!(renderer_status.job_id, "job-playable-stems"); + assert_eq!( + playable_stem_artifact_set + .expect("consumed native artifact reference should remain available") + .stem_artifacts() + .len(), + 4 + ); +} + +#[test] +fn final_process_status_replaces_an_earlier_stem_reference() { + let mut succeeded_with_stems = succeeded_status(); + succeeded_with_stems + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + let first_process_status = parse_status_value(succeeded_with_stems) + .expect("succeeded status with a complete stem set should parse"); + let final_process_status = parse_status_value(succeeded_status()) + .expect("a later succeeded status without stems should parse"); + + let mut latest_process_status = None; + let first_renderer_status = + retain_latest_process_status(&mut latest_process_status, first_process_status); + assert!(matches!( + first_renderer_status.state, + AnalysisJobState::Succeeded + )); + assert!(latest_process_status + .as_ref() + .and_then(AnalysisProcessStatus::playable_stem_artifact_set) + .is_some()); + + let final_renderer_status = + retain_latest_process_status(&mut latest_process_status, final_process_status); + assert!(matches!( + final_renderer_status.state, + AnalysisJobState::Succeeded + )); + assert!(latest_process_status + .as_ref() + .expect("the final process envelope should be retained") + .playable_stem_artifact_set() + .is_none()); +} + +#[test] +fn process_status_line_parser_ignores_only_blank_lines() { + assert!(parse_analysis_process_status_line(" \n\t") + .expect("blank JSONL line should be ignorable") + .is_none()); + + let malformed_error = parse_analysis_process_status_line("not-json") + .expect_err("non-empty malformed JSONL must fail closed"); + assert_eq!(malformed_error, PROCESS_STATUS_ERROR); + + let valid_status_json = serde_json::to_string(&succeeded_status()) + .expect("process status fixture should serialize"); + let valid_status = parse_analysis_process_status_line(&valid_status_json) + .expect("valid non-empty JSONL should parse") + .expect("valid non-empty JSONL should produce an envelope"); + assert!(matches!( + valid_status.renderer_status().state, + AnalysisJobState::Succeeded + )); +} + +#[test] +fn preserves_legacy_status_without_playable_stem_metadata() { + let process_status = + parse_status_value(queued_status()).expect("legacy queued status should parse"); + + assert_eq!(process_status.renderer_status().job_id, "job-queued"); + assert!(process_status.playable_stem_artifact_set().is_none()); +} + +#[test] +fn rejects_native_artifact_metadata_on_nonterminal_or_failed_status() { + let mut running_status = queued_status(); + let running_status_object = running_status + .as_object_mut() + .expect("status fixture must remain an object"); + running_status_object.insert("state".to_string(), json!("running")); + running_status_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + let mut failed_status = queued_status(); + let failed_status_object = failed_status + .as_object_mut() + .expect("status fixture must remain an object"); + failed_status_object.insert("state".to_string(), json!("failed")); + failed_status_object.insert( + "error".to_string(), + json!({"code": "engine_unavailable", "message": "Analysis failed."}), + ); + failed_status_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + for invalid_status in [running_status, failed_status] { + assert_invalid_status(invalid_status); + } +} + +#[test] +fn rejects_artifact_metadata_without_a_result_or_with_an_error() { + let mut missing_result = succeeded_status(); + let missing_result_object = missing_result + .as_object_mut() + .expect("status fixture must remain an object"); + missing_result_object.remove("result"); + missing_result_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + let mut success_with_error = succeeded_status(); + let success_with_error_object = success_with_error + .as_object_mut() + .expect("status fixture must remain an object"); + success_with_error_object.insert( + "error".to_string(), + json!({"code": "engine_unavailable", "message": "Contradictory status."}), + ); + success_with_error_object.insert( + "playableStemArtifactSet".to_string(), + playable_stem_artifact_set(), + ); + + for invalid_status in [missing_result, success_with_error] { + assert_invalid_status(invalid_status); + } +} + +#[test] +fn rejects_null_malformed_or_path_bearing_artifact_metadata() { + let mut null_artifact_set = succeeded_status(); + null_artifact_set + .as_object_mut() + .expect("status fixture must remain an object") + .insert("playableStemArtifactSet".to_string(), Value::Null); + + let mut malformed_artifact_set = succeeded_status(); + malformed_artifact_set + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + json!({"artifactSetId": ARTIFACT_SET_ID}), + ); + + let mut path_bearing_artifact_set = playable_stem_artifact_set(); + path_bearing_artifact_set + .get_mut("stemArtifacts") + .and_then(Value::as_array_mut) + .and_then(|stem_artifacts| stem_artifacts.first_mut()) + .and_then(Value::as_object_mut) + .expect("stem artifact fixture must remain an object") + .insert( + "nativeFilePath".to_string(), + json!("/Users/private/audio.wav"), + ); + let mut path_bearing_status = succeeded_status(); + path_bearing_status + .as_object_mut() + .expect("status fixture must remain an object") + .insert( + "playableStemArtifactSet".to_string(), + path_bearing_artifact_set, + ); + + for invalid_status in [ + null_artifact_set, + malformed_artifact_set, + path_bearing_status, + ] { + assert_invalid_status(invalid_status); + } +} + +#[test] +fn preserves_existing_unknown_field_and_json_shape_rejection() { + let mut unknown_status = queued_status(); + unknown_status + .as_object_mut() + .expect("status fixture must remain an object") + .insert("unexpectedField".to_string(), json!(true)); + + assert_invalid_status(unknown_status); + assert_invalid_json("not-json"); + assert_invalid_json("[]"); +} diff --git a/apps/desktop/core/tests/playable_stem_artifact_reference.rs b/apps/desktop/core/tests/playable_stem_artifact_reference.rs new file mode 100644 index 000000000..ee1793742 --- /dev/null +++ b/apps/desktop/core/tests/playable_stem_artifact_reference.rs @@ -0,0 +1,349 @@ +//! Contract tests for path-free playable-stem artifact references. + +use bandscope_desktop_core::playable_stem_contract::{ + PlayableStemArtifactSetReference, PlaybackStemKind, + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT, PLAYABLE_STEM_ARTIFACT_VERSION, +}; +use serde_json::{json, Map, Value}; +use std::path::Path; + +const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const CONTENT_HASH: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn valid_stem_artifact(stem_kind: &str) -> Value { + json!({ + "artifactId": format!("stem-{stem_kind}"), + "stemKind": stem_kind, + "fileSizeBytes": 172, + "contentHashSha256": CONTENT_HASH, + "mediaType": "audio/wav", + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008 + }) +} + +fn valid_reference_value() -> Value { + json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": 8000, + "channelCount": 1, + "sampleCount": 64, + "durationSeconds": 0.008, + "appliedGain": 1.0, + "stemArtifacts": [ + valid_stem_artifact("vocals"), + valid_stem_artifact("bass"), + valid_stem_artifact("drums"), + valid_stem_artifact("other") + ] + }) +} + +fn parse_reference( + reference_value: Value, +) -> Result { + serde_json::from_value(reference_value) +} + +fn reference_object_mut(reference_value: &mut Value) -> &mut Map { + reference_value + .as_object_mut() + .expect("reference fixture must remain an object") +} + +fn stem_artifacts_mut(reference_value: &mut Value) -> &mut Vec { + reference_object_mut(reference_value) + .get_mut("stemArtifacts") + .and_then(Value::as_array_mut) + .expect("stemArtifacts fixture must remain an array") +} + +fn stem_artifact_object_mut( + reference_value: &mut Value, + artifact_index: usize, +) -> &mut Map { + stem_artifacts_mut(reference_value)[artifact_index] + .as_object_mut() + .expect("stem artifact fixture must remain an object") +} + +fn set_sample_geometry(reference_value: &mut Value, sample_count: u64) { + let sample_rate = 8_000_u64; + let duration_seconds = sample_count as f64 / sample_rate as f64; + let file_size_bytes = 44_u64 + (sample_count * 2_u64); + reference_object_mut(reference_value) + .insert("sampleCount".to_string(), json!(sample_count)); + reference_object_mut(reference_value) + .insert("durationSeconds".to_string(), json!(duration_seconds)); + for stem_artifact in stem_artifacts_mut(reference_value) { + let stem_artifact_object = stem_artifact + .as_object_mut() + .expect("stem artifact fixture must remain an object"); + stem_artifact_object.insert("sampleCount".to_string(), json!(sample_count)); + stem_artifact_object.insert("durationSeconds".to_string(), json!(duration_seconds)); + stem_artifact_object.insert("fileSizeBytes".to_string(), json!(file_size_bytes)); + } +} + +#[test] +fn parses_complete_path_free_reference_and_exposes_metadata() { + let artifact_reference = + parse_reference(valid_reference_value()).expect("valid reference should parse"); + + assert_eq!(artifact_reference.artifact_set_id(), ARTIFACT_SET_ID); + assert_eq!( + artifact_reference.format_version(), + PLAYABLE_STEM_ARTIFACT_VERSION + ); + assert_eq!(artifact_reference.sample_rate(), 8000); + assert_eq!(artifact_reference.channel_count(), 1); + assert_eq!(artifact_reference.sample_count(), 64); + assert_eq!(artifact_reference.duration_seconds(), 0.008); + assert_eq!(artifact_reference.applied_gain(), 1.0); + + let stem_artifacts = artifact_reference.stem_artifacts(); + assert_eq!( + stem_artifacts + .iter() + .map(|stem_artifact| stem_artifact.stem_kind()) + .collect::>(), + vec![ + PlaybackStemKind::Vocals, + PlaybackStemKind::Bass, + PlaybackStemKind::Drums, + PlaybackStemKind::Other, + ] + ); + let vocal_artifact = &stem_artifacts[0]; + assert_eq!(vocal_artifact.artifact_id(), "stem-vocals"); + assert_eq!(vocal_artifact.file_size_bytes(), 172); + assert_eq!(vocal_artifact.content_hash_sha256(), CONTENT_HASH); + assert_eq!(vocal_artifact.media_type(), "audio/wav"); + assert_eq!(vocal_artifact.sample_rate(), 8000); + assert_eq!(vocal_artifact.channel_count(), 1); + assert_eq!(vocal_artifact.sample_count(), 64); + assert_eq!(vocal_artifact.duration_seconds(), 0.008); + + for (stem_kind, file_name) in [ + (PlaybackStemKind::Vocals, "vocals.wav"), + (PlaybackStemKind::Bass, "bass.wav"), + (PlaybackStemKind::Drums, "drums.wav"), + (PlaybackStemKind::Other, "other.wav"), + ] { + assert_eq!( + artifact_reference.derive_artifact_path(Path::new("/app/temp"), stem_kind), + Path::new("/app/temp") + .join("playable-stems-v1") + .join(ARTIFACT_SET_ID) + .join(file_name) + ); + } +} + +#[test] +fn serialized_reference_never_contains_a_native_path() { + let artifact_reference = + parse_reference(valid_reference_value()).expect("valid reference should parse"); + let serialized_reference = + serde_json::to_string(&artifact_reference).expect("reference should serialize"); + + assert!(!serialized_reference.to_ascii_lowercase().contains("path")); + assert!(!serialized_reference.contains("/app/temp")); + let reparsed_reference: PlayableStemArtifactSetReference = + serde_json::from_str(&serialized_reference).expect("serialized reference should parse"); + assert_eq!(reparsed_reference, artifact_reference); +} + +#[test] +fn rejects_unknown_path_and_storage_fields() { + for (field_name, field_value) in [ + ("nativeFilePath", "/secret/audio.wav"), + ("artifactRoot", "/secret"), + ("sourcePath", "C:\\secret\\audio.wav"), + ] { + let mut malformed_reference = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_reference, 0) + .insert(field_name.to_string(), json!(field_value)); + assert!(parse_reference(malformed_reference).is_err()); + } + + let mut malformed_set = valid_reference_value(); + reference_object_mut(&mut malformed_set) + .insert("storageRoot".to_string(), json!("/secret")); + assert!(parse_reference(malformed_set).is_err()); +} + +#[test] +fn rejects_invalid_set_artifact_and_hash_identifiers() { + let mut malformed_set_case = valid_reference_value(); + reference_object_mut(&mut malformed_set_case) + .insert("artifactSetId".to_string(), json!("A".repeat(64))); + + let mut malformed_set_path = valid_reference_value(); + reference_object_mut(&mut malformed_set_path) + .insert("artifactSetId".to_string(), json!("a/../../b")); + + let mut malformed_artifact_id = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_artifact_id, 0) + .insert("artifactId".to_string(), json!("stem-bass")); + + let mut malformed_hash_case = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_hash_case, 0) + .insert("contentHashSha256".to_string(), json!("B".repeat(64))); + + let mut malformed_hash_length = valid_reference_value(); + stem_artifact_object_mut(&mut malformed_hash_length, 0) + .insert("contentHashSha256".to_string(), json!("b".repeat(63))); + + for malformed_reference in [ + malformed_set_case, + malformed_set_path, + malformed_artifact_id, + malformed_hash_case, + malformed_hash_length, + ] { + assert!(parse_reference(malformed_reference).is_err()); + } +} + +#[test] +fn rejects_missing_duplicate_reordered_or_unknown_stems() { + let mut missing_reference = valid_reference_value(); + stem_artifacts_mut(&mut missing_reference).remove(0); + + let mut duplicate_reference = valid_reference_value(); + let duplicate_artifact = stem_artifacts_mut(&mut duplicate_reference)[0].clone(); + stem_artifacts_mut(&mut duplicate_reference).push(duplicate_artifact); + + let mut reordered_reference = valid_reference_value(); + stem_artifacts_mut(&mut reordered_reference).swap(0, 1); + + let mut unknown_reference = valid_reference_value(); + stem_artifact_object_mut(&mut unknown_reference, 3) + .insert("stemKind".to_string(), json!("guitar")); + + for malformed_reference in [ + missing_reference, + duplicate_reference, + reordered_reference, + unknown_reference, + ] { + assert!(parse_reference(malformed_reference).is_err()); + } +} + +#[test] +fn rejects_set_level_version_media_and_alignment_mismatch() { + let mut unsupported_version = valid_reference_value(); + reference_object_mut(&mut unsupported_version) + .insert("formatVersion".to_string(), json!(2)); + + let mut low_sample_rate = valid_reference_value(); + reference_object_mut(&mut low_sample_rate) + .insert("sampleRate".to_string(), json!(7999)); + + let mut high_sample_rate = valid_reference_value(); + reference_object_mut(&mut high_sample_rate) + .insert("sampleRate".to_string(), json!(192001)); + + let mut stereo_reference = valid_reference_value(); + reference_object_mut(&mut stereo_reference) + .insert("channelCount".to_string(), json!(2)); + + let mut empty_reference = valid_reference_value(); + reference_object_mut(&mut empty_reference) + .insert("sampleCount".to_string(), json!(0)); + + let mut duration_mismatch = valid_reference_value(); + reference_object_mut(&mut duration_mismatch) + .insert("durationSeconds".to_string(), json!(0.009)); + + let mut zero_gain = valid_reference_value(); + reference_object_mut(&mut zero_gain).insert("appliedGain".to_string(), json!(0.0)); + + let mut excessive_gain = valid_reference_value(); + reference_object_mut(&mut excessive_gain) + .insert("appliedGain".to_string(), json!(1.1)); + + for malformed_reference in [ + unsupported_version, + low_sample_rate, + high_sample_rate, + stereo_reference, + empty_reference, + duration_mismatch, + zero_gain, + excessive_gain, + ] { + assert!(parse_reference(malformed_reference).is_err()); + } +} + +#[test] +fn enforces_classic_riff_sample_count_boundary() { + let mut maximum_reference = valid_reference_value(); + set_sample_geometry( + &mut maximum_reference, + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT, + ); + let parsed_maximum = + parse_reference(maximum_reference).expect("classic RIFF maximum should parse"); + assert_eq!( + parsed_maximum.sample_count(), + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT + ); + assert_eq!( + parsed_maximum.stem_artifacts()[0].file_size_bytes(), + 4_294_967_302 + ); + + let mut excessive_reference = valid_reference_value(); + set_sample_geometry( + &mut excessive_reference, + MAX_CLASSIC_RIFF_PCM16_SAMPLE_COUNT + 1, + ); + assert!(parse_reference(excessive_reference).is_err()); +} + +#[test] +fn rejects_each_per_stem_metadata_mismatch() { + let mut size_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut size_mismatch, 0) + .insert("fileSizeBytes".to_string(), json!(171)); + + let mut media_type_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut media_type_mismatch, 0) + .insert("mediaType".to_string(), json!("audio/mpeg")); + + let mut sample_rate_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut sample_rate_mismatch, 0) + .insert("sampleRate".to_string(), json!(16000)); + + let mut channel_count_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut channel_count_mismatch, 0) + .insert("channelCount".to_string(), json!(2)); + + let mut sample_count_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut sample_count_mismatch, 0) + .insert("sampleCount".to_string(), json!(63)); + + let mut duration_mismatch = valid_reference_value(); + stem_artifact_object_mut(&mut duration_mismatch, 0) + .insert("durationSeconds".to_string(), json!(0.007)); + + for malformed_reference in [ + size_mismatch, + media_type_mismatch, + sample_rate_mismatch, + channel_count_mismatch, + sample_count_mismatch, + duration_mismatch, + ] { + assert!(parse_reference(malformed_reference).is_err()); + } +} diff --git a/apps/desktop/core/tests/project_persistence_contract.rs b/apps/desktop/core/tests/project_persistence_contract.rs new file mode 100644 index 000000000..c862acb98 --- /dev/null +++ b/apps/desktop/core/tests/project_persistence_contract.rs @@ -0,0 +1,134 @@ +use bandscope_desktop_core::project_payload_from_content; +use serde_json::{json, Value}; + +fn current_rehearsal_song() -> Value { + json!({ + "id": "demo-song", + "title": "Late Night Set", + "tempo": 120, + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths with a late snare feel", + "timeRange": { "start": 10, "end": 30 }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Double-check the pickup into the chorus." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi pedal anchor", + "source": "model" + }, + "harmonicExplanation": "The bass holds the tonal floor through the pickup.", + "cue": { + "kind": "transition", + "value": "Hold through the pickup before the downbeat." + }, + "range": { "lowestNote": "C#2", "highestNote": "E3" }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Watch the slide into the turnaround." + }, + "rehearsalPriority": "high", + "simplification": "Stay on roots if the chorus entrance gets muddy.", + "setupNote": "Keep the attack short so the verse breathes.", + "transpositionPlan": "Move the shape down a whole step if the singer changes key.", + "manualOverrides": [], + "overlapWarnings": [], + "transcription": [ + { "pitch": "C#2", "onset": 10.0, "offset": 10.5, "velocity": 0.8 } + ], + "practiceProgress": 45 + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse handoff and low-register overlap.", + "focusSections": ["verse-1"] + }, + "collaboration": { + "syncMode": "local_only", + "syncNote": "Keep rehearsal coordination on this device.", + "assignments": [ + { + "id": "assign-bass", + "assignee": "Rhythm Section", + "summary": "Lock the pickup.", + "sectionId": "verse-1", + "roleId": "bass-guitar", + "status": "in_progress" + } + ], + "comments": [ + { + "id": "comment-bass", + "author": "MD", + "body": "Keep the attack short.", + "sectionId": "verse-1", + "roleId": "bass-guitar", + "status": "open" + } + ], + "approvals": [ + { + "id": "approval-bass", + "scope": "Verse rhythm pass", + "owner": "MD", + "status": "pending" + } + ] + } + }) +} + +#[test] +fn project_persistence_round_trips_current_shared_song_fields() { + let content = serde_json::to_string(¤t_rehearsal_song()) + .expect("current rehearsal song should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project persistence must accept the current shared rehearsal song contract"); + let round_trip = serde_json::to_value(parsed) + .expect("native project payload should serialize back to renderer JSON"); + + assert_eq!(round_trip["tempo"], json!(120.0)); + assert_eq!( + round_trip["sections"][0]["roles"][0]["harmonicExplanation"], + json!("The bass holds the tonal floor through the pickup.") + ); + assert_eq!( + round_trip["sections"][0]["roles"][0]["transpositionPlan"], + json!("Move the shape down a whole step if the singer changes key.") + ); + assert_eq!( + round_trip["sections"][0]["roles"][0]["transcription"][0]["pitch"], + json!("C#2") + ); + assert_eq!( + round_trip["sections"][0]["roles"][0]["practiceProgress"], + json!(45) + ); + assert_eq!( + round_trip["collaboration"]["assignments"][0]["roleId"], + json!("bass-guitar") + ); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 000000000..fef9da865 --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,8 @@ +//! Native desktop domain services that are independent of the Tauri command surface. +//! +//! Keeping filesystem admission logic here lets BandScope test hostile local media +//! without granting the renderer path or playback authority. + +pub mod native_file_identity; +pub mod playable_stem_admission; +pub mod playback_source_availability; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 6e142bd1f..eac5929e9 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,7 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod playback_protocol; +mod playback_source_availability_command; +use bandscope_desktop::playable_stem_admission::preflight_playable_stem_set; use bandscope_desktop_core::*; use playback_protocol::{playback_authority_uri, PlaybackAuthority, PLAYBACK_SCHEME}; use rfd::FileDialog; @@ -329,21 +331,29 @@ fn renderer_bootstrap_summary( Ok(summary) } -fn drain_analysis_status_updates( +fn drain_analysis_process_status_updates( state: &AppState, app: &tauri::AppHandle, - status_rx: &mpsc::Receiver, - last_status: &mut Option, + process_status_rx: &mpsc::Receiver, + latest_process_status: &mut Option, ) { - while let Ok(status) = status_rx.try_recv() { - store_status_and_emit(state, app, &status); - *last_status = Some(status); + while let Ok(process_status) = process_status_rx.try_recv() { + let emit_as_progress = + analysis_process_status::is_analysis_process_progress_status(&process_status); + let renderer_status = analysis_process_status::retain_latest_process_status( + latest_process_status, + process_status, + ); + if emit_as_progress { + store_status_and_emit(state, app, &renderer_status); + } } } fn run_analysis_engine( state: AppState, app: tauri::AppHandle, + playback_authority: Arc, job_id: String, request: AnalysisJobRequest, requested_at: String, @@ -379,6 +389,10 @@ fn run_analysis_engine( } }; + let playback_project_id = request.project_id.clone(); + let playback_temp_root = request.temp_root.clone(); + let playback_job_id = job_id.clone(); + let process_job_id = job_id.clone(); let payload = json!({ "jobId": job_id.clone(), "request": request, @@ -403,26 +417,30 @@ fn run_analysis_engine( "Analysis engine is unavailable.", ); }; - let (status_tx, status_rx) = mpsc::channel::(); + let (process_status_tx, process_status_rx) = + mpsc::channel::(); let stdout_reader = thread::spawn(move || { let reader = BufReader::new(stdout); - let mut last_status = None; + let mut latest_process_status = None; for line in reader.lines() { - let Ok(line) = line else { - break; - }; - let trimmed = line.trim(); - if trimmed.is_empty() { + let line = line.map_err(|_| ())?; + let Some(process_status) = + analysis_process_status::parse_analysis_process_status_line(&line) + .map_err(|_| ())? + else { continue; - } - if let Ok(status) = serde_json::from_str::(trimmed) { - last_status = Some(status.clone()); - if status_tx.send(status).is_err() { - break; - } + }; + analysis_process_status::validate_analysis_process_status_for_job( + &process_status, + &process_job_id, + ) + .map_err(|_| ())?; + latest_process_status = Some(process_status.clone()); + if process_status_tx.send(process_status).is_err() { + break; } } - last_status + Ok::<_, ()>(latest_process_status) }); let stderr_reader = thread::spawn(move || { let mut reader = stderr; @@ -450,10 +468,15 @@ fn run_analysis_engine( } let deadline = Instant::now() + ANALYSIS_PROCESS_TIMEOUT; - let mut last_status = None; + let mut latest_process_status = None; let exit_status; loop { - drain_analysis_status_updates(&state, &app, &status_rx, &mut last_status); + drain_analysis_process_status_updates( + &state, + &app, + &process_status_rx, + &mut latest_process_status, + ); match process.try_wait() { Ok(Some(status)) => { exit_status = status; @@ -494,11 +517,30 @@ fn run_analysis_engine( } } } - let reader_last_status = stdout_reader.join().unwrap_or(None); + let reader_latest_process_status = match stdout_reader.join() { + Ok(Ok(latest_process_status)) => latest_process_status, + _ => { + let _ = stderr_reader.join(); + return failed_status( + payload["jobId"] + .as_str() + .unwrap_or("unknown-job") + .to_string(), + requested_at, + AnalysisJobErrorCode::EngineUnavailable, + "Analysis engine returned an invalid response.", + ); + } + }; let _ = stderr_reader.join(); - drain_analysis_status_updates(&state, &app, &status_rx, &mut last_status); - if last_status.is_none() { - last_status = reader_last_status; + drain_analysis_process_status_updates( + &state, + &app, + &process_status_rx, + &mut latest_process_status, + ); + if latest_process_status.is_none() { + latest_process_status = reader_latest_process_status; } if !exit_status.success() { @@ -513,17 +555,37 @@ fn run_analysis_engine( ); } - last_status.unwrap_or_else(|| { - failed_status( - payload["jobId"] - .as_str() - .unwrap_or("unknown-job") - .to_string(), - requested_at, - AnalysisJobErrorCode::EngineUnavailable, - "Analysis engine returned an invalid response.", - ) - }) + let final_process_status = match analysis_process_status::validate_final_analysis_process_status( + latest_process_status.as_ref(), + &playback_job_id, + ) { + Ok(process_status) => process_status, + Err(_) => { + return failed_status( + payload["jobId"] + .as_str() + .unwrap_or("unknown-job") + .to_string(), + requested_at, + AnalysisJobErrorCode::EngineUnavailable, + "Analysis engine returned an invalid response.", + ) + } + }; + let finished = final_process_status.renderer_status().clone(); + if matches!(finished.state, AnalysisJobState::Succeeded) { + let final_artifact_set = final_process_status.playable_stem_artifact_set(); + if let (Some(project_id), Some(temp_root), Some(artifact_set)) = ( + playback_project_id.as_deref(), + playback_temp_root.as_deref(), + final_artifact_set, + ) { + if let Ok(preflight) = preflight_playable_stem_set(Path::new(temp_root), artifact_set) { + let _ = playback_authority.activate_stems(project_id, &playback_job_id, &preflight); + } + } + } + finished } #[tauri::command] @@ -531,6 +593,7 @@ fn start_analysis_job( request: Value, app: tauri::AppHandle, state: tauri::State<'_, AppState>, + playback_authority: tauri::State<'_, Arc>, ) -> AnalysisJobStatus { let requested_at = iso_timestamp_now(); let mut parsed_request = match parse_request_payload(request) { @@ -580,6 +643,9 @@ fn start_analysis_job( "Analysis queue is full. Please wait for a running job to finish.", ); } + if let Some(project_id) = parsed_request.project_id.as_deref() { + let _ = playback_authority.begin_stem_analysis(project_id, &job_id); + } let queued = AnalysisJobStatus { job_id: job_id.clone(), state: AnalysisJobState::Queued, @@ -596,6 +662,7 @@ fn start_analysis_job( let app_state = state.inner().clone(); let worker_app_handle = app.clone(); + let worker_playback_authority = playback_authority.inner().clone(); std::thread::spawn(move || { store_status_and_emit( &app_state, @@ -616,6 +683,7 @@ fn start_analysis_job( let finished = run_analysis_engine( app_state.clone(), worker_app_handle.clone(), + worker_playback_authority, job_id, parsed_request, requested_at, @@ -896,6 +964,7 @@ fn main() { import_youtube_url, start_analysis_job, get_analysis_job_status, + playback_source_availability_command::get_playback_source_availability, save_project, load_project, attach_score_pdf, diff --git a/apps/desktop/src-tauri/src/native_file_identity.rs b/apps/desktop/src-tauri/src/native_file_identity.rs new file mode 100644 index 000000000..6ec26a3b5 --- /dev/null +++ b/apps/desktop/src-tauri/src/native_file_identity.rs @@ -0,0 +1,105 @@ +//! Canonical native file identity used by desktop media admission and playback revocation. +//! +//! Identity is captured from an already-open file descriptor/handle. Callers compare +//! it again when reopening a path so same-size replacement or in-place mutation cannot +//! silently inherit previously granted playback authority. + +use std::fs::File; + +#[cfg(unix)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NativeFileIdentity { + device: u64, + inode: u64, + change_time_seconds: i64, + change_time_nanoseconds: i64, +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(C)] +struct WindowsFileTime { + low_date_time: u32, + high_date_time: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsByHandleFileInformation { + file_attributes: u32, + creation_time: WindowsFileTime, + last_access_time: WindowsFileTime, + last_write_time: WindowsFileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, +} + +#[cfg(windows)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NativeFileIdentity { + volume_serial_number: u32, + file_index: u64, + last_write_time: WindowsFileTime, +} + +#[cfg(not(any(unix, windows)))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NativeFileIdentity; + +/// Capture stable identity from an already-open native file. +/// +/// Unix uses device/inode plus ctime; Windows uses volume/file index plus last-write +/// time. Platforms without an equivalent supported primitive fail closed. +#[cfg(unix)] +pub fn native_file_identity(file: &File) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(NativeFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + change_time_seconds: metadata.ctime(), + change_time_nanoseconds: metadata.ctime_nsec(), + }) +} + +#[cfg(windows)] +pub fn native_file_identity(file: &File) -> std::io::Result { + use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "GetFileInformationByHandle"] + fn get_file_information_by_handle( + file: std::os::windows::io::RawHandle, + information: *mut WindowsByHandleFileInformation, + ) -> i32; + } + + let mut information = MaybeUninit::::uninit(); + let result = unsafe { + get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(NativeFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: ((information.file_index_high as u64) << 32) + | information.file_index_low as u64, + last_write_time: information.last_write_time, + }) +} + +#[cfg(not(any(unix, windows)))] +pub fn native_file_identity(_file: &File) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "native playback file identity is unsupported on this platform", + )) +} diff --git a/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs b/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs new file mode 100644 index 000000000..c5c9be910 --- /dev/null +++ b/apps/desktop/src-tauri/src/playable_stem_admission/mod.rs @@ -0,0 +1,567 @@ +//! Native actual-file preflight for generated rehearsal stem WAVs. +//! +//! A path-free `PlayableStemArtifactSetReference` is metadata, not playback +//! authority. This module derives the only permitted paths from the native-owned +//! project temp root and validates the complete four-file set before the existing +//! Active Player authority may retain any of it. No path from this module is +//! serializable to the renderer. + +mod sha256; + +use crate::native_file_identity::{native_file_identity, NativeFileIdentity}; +use bandscope_desktop_core::playable_stem_contract::{ + PlaybackStemKind, PlayableStemArtifactReference, PlayableStemArtifactSetReference, +}; +use sha256::sha256_hex_reader; +use std::{ + collections::BTreeSet, + ffi::OsString, + fs::{self, File, Metadata}, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, +}; + +const CANONICAL_WAVE_HEADER_BYTES: usize = 44; +const PCM16_BYTES_PER_SAMPLE: u64 = 2; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +/// Stable, payload-free reasons native stem preflight can fail. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlayableStemAdmissionError { + /// The project-owned temp root is absent, non-directory, or redirecting. + InvalidProjectTempRoot, + /// The version/set directory does not have the exact canonical shape. + InvalidArtifactSetLayout, + /// One expected artifact is absent, redirected, non-regular, or unreadable. + InvalidArtifactFile, + /// File length does not match the path-free artifact contract. + FileSizeMismatch, + /// The file is not canonical mono PCM16 RIFF/WAVE matching the metadata. + WaveHeaderMismatch, + /// Complete-file SHA-256 differs from the path-free contract. + ContentHashMismatch, +} + +impl std::fmt::Display for PlayableStemAdmissionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::InvalidProjectTempRoot => "playable stem project temp root is invalid", + Self::InvalidArtifactSetLayout => "playable stem artifact-set layout is invalid", + Self::InvalidArtifactFile => "playable stem artifact file is invalid", + Self::FileSizeMismatch => "playable stem artifact size does not match metadata", + Self::WaveHeaderMismatch => "playable stem WAV header does not match metadata", + Self::ContentHashMismatch => "playable stem content hash does not match metadata", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for PlayableStemAdmissionError {} + +/// One actual file that passed native path/layout/byte/header/hash preflight. +/// +/// The type intentionally does not implement `Serialize`; the canonical path and +/// native file identity are trusted-process state, not a renderer contract or +/// playback handle. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightPlayableStemFile { + stem_kind: PlaybackStemKind, + native_path: PathBuf, + file_size_bytes: u64, + content_hash_sha256: String, + file_identity: NativeFileIdentity, +} + +impl PreflightPlayableStemFile { + /// Return which canonical stem this file represents. + pub const fn stem_kind(&self) -> PlaybackStemKind { + self.stem_kind + } + + /// Return the canonical native-only path for later authority binding. + pub fn native_path(&self) -> &Path { + &self.native_path + } + + /// Return the byte length checked on the opened file. + pub const fn file_size_bytes(&self) -> u64 { + self.file_size_bytes + } + + /// Return the SHA-256 recomputed across the complete opened file. + pub fn content_hash_sha256(&self) -> &str { + &self.content_hash_sha256 + } + + /// Return the identity captured from the same opened file after hash validation. + pub fn file_identity(&self) -> &NativeFileIdentity { + &self.file_identity + } +} + +/// Complete preflight result. Partial stem sets are never returned. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightPlayableStemSet { + artifact_set_id: String, + files: Vec, +} + +impl PreflightPlayableStemSet { + /// Return the path-free artifact-set identity verified on disk. + pub fn artifact_set_id(&self) -> &str { + &self.artifact_set_id + } + + /// Return all four files in canonical vocals/bass/drums/other order. + pub fn files(&self) -> &[PreflightPlayableStemFile] { + &self.files + } +} + +/// Verify actual generated stem bytes without granting playback authority. +/// +/// The native identity is captured from the same file handle whose complete bytes +/// were hashed. A later playback open must match that identity, closing the +/// preflight-to-authority replacement gap without accepting a producer path. +pub fn preflight_playable_stem_set( + project_temp_root: &Path, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result { + if !project_temp_root.is_absolute() { + return Err(PlayableStemAdmissionError::InvalidProjectTempRoot); + } + validate_directory(project_temp_root) + .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; + let canonical_temp_root = project_temp_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidProjectTempRoot)?; + + let version_root = canonical_temp_root.join("playable-stems-v1"); + validate_directory(&version_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + let canonical_version_root = version_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + if canonical_version_root.parent() != Some(canonical_temp_root.as_path()) { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + let artifact_set_root = canonical_version_root.join(artifact_set.artifact_set_id()); + validate_directory(&artifact_set_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + let canonical_artifact_set_root = artifact_set_root + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)?; + if canonical_artifact_set_root.parent() != Some(canonical_version_root.as_path()) { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + validate_exact_artifact_members(&canonical_artifact_set_root)?; + + let expected_stems = PlaybackStemKind::canonical_order(); + let mut verified_files = Vec::with_capacity(expected_stems.len()); + for (artifact, expected_stem) in artifact_set + .stem_artifacts() + .iter() + .zip(expected_stems) + { + if artifact.stem_kind() != expected_stem { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + verified_files.push(preflight_artifact( + &canonical_temp_root, + &canonical_artifact_set_root, + artifact, + artifact_set, + )?); + } + + if verified_files.len() != expected_stems.len() { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + Ok(PreflightPlayableStemSet { + artifact_set_id: artifact_set.artifact_set_id().to_string(), + files: verified_files, + }) +} + +fn validate_exact_artifact_members( + artifact_set_root: &Path, +) -> Result<(), PlayableStemAdmissionError> { + let actual_members = fs::read_dir(artifact_set_root) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout)? + .map(|entry| { + entry + .map(|entry| entry.file_name()) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactSetLayout) + }) + .collect::, _>>()?; + let expected_members = PlaybackStemKind::canonical_order() + .into_iter() + .map(|stem_kind| OsString::from(stem_kind.file_name())) + .collect::>(); + + if actual_members != expected_members { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + Ok(()) +} + +fn preflight_artifact( + canonical_temp_root: &Path, + artifact_set_root: &Path, + artifact: &PlayableStemArtifactReference, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result { + let native_path = artifact_set.derive_artifact_path(canonical_temp_root, artifact.stem_kind()); + let expected_path = artifact_set_root.join(artifact.stem_kind().file_name()); + if native_path != expected_path { + return Err(PlayableStemAdmissionError::InvalidArtifactSetLayout); + } + + let link_metadata = fs::symlink_metadata(&native_path) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if link_metadata.file_type().is_symlink() + || is_reparse_point(&link_metadata) + || !link_metadata.is_file() + { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + if link_metadata.len() != artifact.file_size_bytes() { + return Err(PlayableStemAdmissionError::FileSizeMismatch); + } + + let canonical_path = native_path + .canonicalize() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if canonical_path.parent() != Some(artifact_set_root) + || canonical_path.file_name() + != Some(OsString::from(artifact.stem_kind().file_name()).as_os_str()) + { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + + let mut file = File::open(&canonical_path) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + let opened_metadata = file + .metadata() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if !opened_metadata.is_file() || opened_metadata.len() != artifact.file_size_bytes() { + return Err(PlayableStemAdmissionError::FileSizeMismatch); + } + + validate_wave_header(&mut file, artifact, artifact_set)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + let content_hash_sha256 = sha256_hex_reader(&mut file) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if content_hash_sha256 != artifact.content_hash_sha256() { + return Err(PlayableStemAdmissionError::ContentHashMismatch); + } + + let final_metadata = file + .metadata() + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + if !final_metadata.is_file() || final_metadata.len() != opened_metadata.len() { + return Err(PlayableStemAdmissionError::InvalidArtifactFile); + } + let file_identity = native_file_identity(&file) + .map_err(|_| PlayableStemAdmissionError::InvalidArtifactFile)?; + + Ok(PreflightPlayableStemFile { + stem_kind: artifact.stem_kind(), + native_path: canonical_path, + file_size_bytes: final_metadata.len(), + content_hash_sha256, + file_identity, + }) +} + +fn validate_wave_header( + file: &mut File, + artifact: &PlayableStemArtifactReference, + artifact_set: &PlayableStemArtifactSetReference, +) -> Result<(), PlayableStemAdmissionError> { + let mut header = [0u8; CANONICAL_WAVE_HEADER_BYTES]; + file.seek(SeekFrom::Start(0)) + .and_then(|_| file.read_exact(&mut header)) + .map_err(|_| PlayableStemAdmissionError::WaveHeaderMismatch)?; + + let expected_data_size = artifact_set + .sample_count() + .checked_mul(PCM16_BYTES_PER_SAMPLE) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + let expected_riff_size = artifact + .file_size_bytes() + .checked_sub(8) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + let expected_byte_rate = artifact_set + .sample_rate() + .checked_mul(PCM16_BYTES_PER_SAMPLE as u32) + .ok_or(PlayableStemAdmissionError::WaveHeaderMismatch)?; + + if &header[0..4] != b"RIFF" + || read_u32_le(&header[4..8]) != Some(expected_riff_size) + || &header[8..12] != b"WAVE" + || &header[12..16] != b"fmt " + || read_u32_le(&header[16..20]) != Some(16) + || read_u16_le(&header[20..22]) != Some(1) + || read_u16_le(&header[22..24]) != Some(1) + || read_u32_le(&header[24..28]) != Some(artifact_set.sample_rate()) + || read_u32_le(&header[28..32]) != Some(expected_byte_rate) + || read_u16_le(&header[32..34]) != Some(2) + || read_u16_le(&header[34..36]) != Some(16) + || &header[36..40] != b"data" + || read_u32_le(&header[40..44]) != Some(expected_data_size) + || artifact.sample_rate() != artifact_set.sample_rate() + || artifact.channel_count() != 1 + || artifact.sample_count() != artifact_set.sample_count() + { + return Err(PlayableStemAdmissionError::WaveHeaderMismatch); + } + Ok(()) +} + +fn read_u16_le(bytes: &[u8]) -> Option { + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_u32_le(bytes: &[u8]) -> Option { + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn validate_directory(path: &Path) -> Result<(), ()> { + let metadata = fs::symlink_metadata(path).map_err(|_| ())?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(()); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &Metadata) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + io::Cursor, + time::{SystemTime, UNIX_EPOCH}, + }; + + const ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn unique_temp_root(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-stem-admission-{}-{label}-{unique}", + std::process::id() + )); + fs::create_dir_all(&root).expect("test temp root should be created"); + root.canonicalize().expect("test temp root should canonicalize") + } + + fn pcm16_wave(sample_rate: u32, samples: &[i16]) -> Vec { + let data_size = u32::try_from(samples.len() * 2).expect("test PCM data should fit RIFF"); + let mut bytes = Vec::with_capacity(CANONICAL_WAVE_HEADER_BYTES + data_size as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_size).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_size.to_le_bytes()); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes + } + + fn artifact_set_for_bytes( + sample_rate: u32, + stem_bytes: &[Vec], + ) -> PlayableStemArtifactSetReference { + let sample_count = ((stem_bytes[0].len() - CANONICAL_WAVE_HEADER_BYTES) / 2) as u64; + let duration_seconds = sample_count as f64 / sample_rate as f64; + let artifacts = PlaybackStemKind::canonical_order() + .into_iter() + .zip(stem_bytes) + .map(|(stem_kind, bytes)| { + json!({ + "artifactId": stem_kind.artifact_id(), + "stemKind": match stem_kind { + PlaybackStemKind::Vocals => "vocals", + PlaybackStemKind::Bass => "bass", + PlaybackStemKind::Drums => "drums", + PlaybackStemKind::Other => "other", + }, + "fileSizeBytes": bytes.len(), + "contentHashSha256": sha256_hex_reader(Cursor::new(bytes)).expect("fixture should hash"), + "mediaType": "audio/wav", + "sampleRate": sample_rate, + "channelCount": 1, + "sampleCount": sample_count, + "durationSeconds": duration_seconds + }) + }) + .collect::>(); + + serde_json::from_value(json!({ + "artifactSetId": ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": sample_rate, + "channelCount": 1, + "sampleCount": sample_count, + "durationSeconds": duration_seconds, + "appliedGain": 1.0, + "stemArtifacts": artifacts + })) + .expect("artifact-set fixture should satisfy the core contract") + } + + fn write_set(root: &Path, stem_bytes: &[Vec]) -> PathBuf { + let set_root = root.join("playable-stems-v1").join(ARTIFACT_SET_ID); + fs::create_dir_all(&set_root).expect("artifact-set fixture directory should be created"); + for (stem_kind, bytes) in PlaybackStemKind::canonical_order().into_iter().zip(stem_bytes) { + fs::write(set_root.join(stem_kind.file_name()), bytes) + .expect("artifact fixture should be written"); + } + set_root + } + + fn canonical_fixture() -> (PathBuf, PlayableStemArtifactSetReference, Vec>) { + let root = unique_temp_root("valid"); + let samples = [0i16, 1, -1, i16::MAX, i16::MIN, 120, -120, 7]; + let stem_bytes = (0..4) + .map(|_| pcm16_wave(8_000, &samples)) + .collect::>(); + let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); + write_set(&root, &stem_bytes); + (root, artifact_set, stem_bytes) + } + + #[test] + fn accepts_only_the_complete_canonical_pcm16_set() { + let (root, artifact_set, _) = canonical_fixture(); + let preflight = preflight_playable_stem_set(&root, &artifact_set) + .expect("complete canonical set should pass native preflight"); + + assert_eq!(preflight.artifact_set_id(), ARTIFACT_SET_ID); + assert_eq!( + preflight + .files() + .iter() + .map(PreflightPlayableStemFile::stem_kind) + .collect::>(), + PlaybackStemKind::canonical_order() + ); + assert!(preflight + .files() + .iter() + .all(|file| file.native_path().starts_with(&root))); + for file in preflight.files() { + assert_eq!(file.file_identity(), file.file_identity()); + } + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_relative_project_temp_root() { + let (_, artifact_set, _) = canonical_fixture(); + assert_eq!( + preflight_playable_stem_set(Path::new("relative-project-temp"), &artifact_set), + Err(PlayableStemAdmissionError::InvalidProjectTempRoot) + ); + } + + #[test] + fn rejects_same_size_content_mutation_by_complete_file_hash() { + let (root, artifact_set, _) = canonical_fixture(); + let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); + let mut mutated = fs::read(&vocals).expect("fixture should read"); + let last = mutated.len() - 1; + mutated[last] ^= 0x01; + fs::write(&vocals, mutated).expect("same-size mutation should write"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::ContentHashMismatch) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_noncanonical_wave_header_even_when_hash_metadata_matches() { + let root = unique_temp_root("header"); + let samples = [0i16; 8]; + let mut stem_bytes = (0..4) + .map(|_| pcm16_wave(8_000, &samples)) + .collect::>(); + stem_bytes[0][34] = 24; + let artifact_set = artifact_set_for_bytes(8_000, &stem_bytes); + write_set(&root, &stem_bytes); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::WaveHeaderMismatch) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_unexpected_artifact_set_members() { + let (root, artifact_set, _) = canonical_fixture(); + let unexpected = root + .join("playable-stems-v1") + .join(ARTIFACT_SET_ID) + .join("notes.txt"); + fs::write(unexpected, b"not media").expect("unexpected member should write"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::InvalidArtifactSetLayout) + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_stem_even_when_target_bytes_match() { + use std::os::unix::fs::symlink; + + let (root, artifact_set, stem_bytes) = canonical_fixture(); + let vocals = artifact_set.derive_artifact_path(&root, PlaybackStemKind::Vocals); + let replacement = root.join("replacement.wav"); + fs::write(&replacement, &stem_bytes[0]).expect("replacement fixture should write"); + fs::remove_file(&vocals).expect("original vocals fixture should be removed"); + symlink(&replacement, &vocals).expect("symlink fixture should be created"); + + assert_eq!( + preflight_playable_stem_set(&root, &artifact_set), + Err(PlayableStemAdmissionError::InvalidArtifactFile) + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs b/apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs new file mode 100644 index 000000000..82153de27 --- /dev/null +++ b/apps/desktop/src-tauri/src/playable_stem_admission/sha256.rs @@ -0,0 +1,277 @@ +//! Streaming SHA-256 used only for local playable-stem integrity comparison. +//! +//! The operations and constants follow NIST FIPS 180-4 SHA-256. The known-answer +//! tests below are correctness checks, not CAVP validation or a FIPS 140 claim. + +use std::io::{self, Read}; + +const BLOCK_BYTES: usize = 64; +const DIGEST_BYTES: usize = 32; +const INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, + 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, + 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, + 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, + 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, + 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, + 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, + 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, + 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, + 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, + 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, +]; + +#[derive(Clone)] +struct Sha256State { + words: [u32; 8], + buffer: [u8; BLOCK_BYTES], + buffer_len: usize, + message_len_bytes: u64, +} + +impl Default for Sha256State { + fn default() -> Self { + Self { + words: INITIAL_STATE, + buffer: [0; BLOCK_BYTES], + buffer_len: 0, + message_len_bytes: 0, + } + } +} + +impl Sha256State { + fn update(&mut self, mut bytes: &[u8]) -> io::Result<()> { + self.message_len_bytes = self + .message_len_bytes + .checked_add(bytes.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input too large"))?; + + if self.buffer_len != 0 { + let copied = (BLOCK_BYTES - self.buffer_len).min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + copied] + .copy_from_slice(&bytes[..copied]); + self.buffer_len += copied; + bytes = &bytes[copied..]; + if self.buffer_len == BLOCK_BYTES { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } + } + + while bytes.len() >= BLOCK_BYTES { + let block: &[u8; BLOCK_BYTES] = bytes[..BLOCK_BYTES] + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid SHA-256 block"))?; + self.compress(block); + bytes = &bytes[BLOCK_BYTES..]; + } + + if !bytes.is_empty() { + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + Ok(()) + } + + fn finalize(mut self) -> io::Result<[u8; DIGEST_BYTES]> { + let message_len_bits = self + .message_len_bytes + .checked_mul(8) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "SHA-256 input too large"))?; + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; BLOCK_BYTES]; + self.buffer_len = 0; + } + self.buffer[self.buffer_len..56].fill(0); + self.buffer[56..].copy_from_slice(&message_len_bits.to_be_bytes()); + let block = self.buffer; + self.compress(&block); + + let mut digest = [0u8; DIGEST_BYTES]; + for (index, word) in self.words.into_iter().enumerate() { + digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + Ok(digest) + } + + fn compress(&mut self, block: &[u8; BLOCK_BYTES]) { + let mut schedule = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + schedule[index] = u32::from_be_bytes( + chunk + .try_into() + .expect("SHA-256 message word always contains four bytes"), + ); + } + for index in 16..64 { + let small_sigma0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let small_sigma1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(small_sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(small_sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.words; + for index in 0..64 { + let big_sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temporary1 = h + .wrapping_add(big_sigma1) + .wrapping_add(choose) + .wrapping_add(ROUND_CONSTANTS[index]) + .wrapping_add(schedule[index]); + let big_sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temporary2 = big_sigma0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temporary1); + d = c; + c = b; + b = a; + a = temporary1.wrapping_add(temporary2); + } + + self.words[0] = self.words[0].wrapping_add(a); + self.words[1] = self.words[1].wrapping_add(b); + self.words[2] = self.words[2].wrapping_add(c); + self.words[3] = self.words[3].wrapping_add(d); + self.words[4] = self.words[4].wrapping_add(e); + self.words[5] = self.words[5].wrapping_add(f); + self.words[6] = self.words[6].wrapping_add(g); + self.words[7] = self.words[7].wrapping_add(h); + } +} + +pub(super) fn sha256_hex_reader(mut reader: impl Read) -> io::Result { + let mut state = Sha256State::default(); + let mut chunk = [0u8; 64 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read_bytes) => state.update(&chunk[..read_bytes])?, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + + let digest = state.finalize()?; + let mut encoded = String::with_capacity(DIGEST_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(encoded) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + struct InterruptedShortReader { + bytes: Vec, + cursor: usize, + interrupted: bool, + } + + impl Read for InterruptedShortReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(io::Error::from(io::ErrorKind::Interrupted)); + } + if self.cursor == self.bytes.len() { + return Ok(0); + } + let copied = 7.min(output.len()).min(self.bytes.len() - self.cursor); + output[..copied].copy_from_slice(&self.bytes[self.cursor..self.cursor + copied]); + self.cursor += copied; + Ok(copied) + } + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _output: &mut [u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Other, "fixture read failure")) + } + } + + #[test] + fn matches_sha256_known_answer_vectors() { + for (message, expected) in [ + ( + &b""[..], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + &b"abc"[..], + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + &b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"[..], + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ] { + assert_eq!(sha256_hex_reader(Cursor::new(message)).as_deref(), Ok(expected)); + } + } + + #[test] + fn hashes_multiple_blocks_and_interrupted_short_reads_identically() { + let bytes = (0..131_111) + .map(|index| (index % 251) as u8) + .collect::>(); + let expected = sha256_hex_reader(Cursor::new(&bytes)).expect("reference hash should succeed"); + let actual = sha256_hex_reader(InterruptedShortReader { + bytes, + cursor: 0, + interrupted: false, + }) + .expect("interrupted short reads should be retried"); + assert_eq!(actual, expected); + } + + #[test] + fn propagates_non_interrupted_reader_failure() { + let error = sha256_hex_reader(FailingReader).expect_err("reader failure must propagate"); + assert_eq!(error.kind(), io::ErrorKind::Other); + } + + #[test] + fn matches_the_million_a_vector() { + let bytes = vec![b'a'; 1_000_000]; + assert_eq!( + sha256_hex_reader(Cursor::new(bytes)).as_deref(), + Ok("cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0") + ); + } +} diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs index 136e47e2d..7b6db0360 100644 --- a/apps/desktop/src-tauri/src/playback_protocol.rs +++ b/apps/desktop/src-tauri/src/playback_protocol.rs @@ -1,11 +1,18 @@ //! Revocable native media authority for the mounted rehearsal player. //! -//! The WebView receives only an app-minted project id. Native source paths stay -//! behind this protocol boundary and every request is checked against the one -//! currently active project before BandScope opens any file. +//! The WebView receives only app-minted opaque identifiers. Native source paths +//! stay behind this protocol boundary and every request is checked against the +//! one currently active project before BandScope opens any file. -use bandscope_desktop_core::{is_valid_project_id, LocalAudioSourcePayload}; +use bandscope_desktop::{ + native_file_identity::{native_file_identity, NativeFileIdentity}, + playable_stem_admission::PreflightPlayableStemSet, +}; +use bandscope_desktop_core::{ + is_valid_project_id, playable_stem_contract::PlaybackStemKind, LocalAudioSourcePayload, +}; use std::{ + collections::BTreeMap, fs::File, io::{Read, Seek, SeekFrom}, path::{Path, PathBuf}, @@ -27,117 +34,37 @@ pub const PLAYBACK_AUTHORITY_PREFIX: &str = "bandscope-project://"; /// an arbitrarily large buffer from an untrusted Range header. const MAX_RANGE_BYTES: u64 = 1_000 * 1024; -#[cfg(unix)] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlaybackFileIdentity { - device: u64, - inode: u64, - change_time_seconds: i64, - change_time_nanoseconds: i64, -} - -#[cfg(windows)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(C)] -struct WindowsFileTime { - low_date_time: u32, - high_date_time: u32, -} - -#[cfg(windows)] -#[repr(C)] -struct WindowsByHandleFileInformation { - file_attributes: u32, - creation_time: WindowsFileTime, - last_access_time: WindowsFileTime, - last_write_time: WindowsFileTime, - volume_serial_number: u32, - file_size_high: u32, - file_size_low: u32, - number_of_links: u32, - file_index_high: u32, - file_index_low: u32, -} - -#[cfg(windows)] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlaybackFileIdentity { - volume_serial_number: u32, - file_index: u64, - last_write_time: WindowsFileTime, -} - -#[cfg(not(any(unix, windows)))] -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlaybackFileIdentity; - -#[cfg(unix)] -fn playback_file_identity(file: &File) -> std::io::Result { - use std::os::unix::fs::MetadataExt; - - let metadata = file.metadata()?; - Ok(PlaybackFileIdentity { - device: metadata.dev(), - inode: metadata.ino(), - change_time_seconds: metadata.ctime(), - change_time_nanoseconds: metadata.ctime_nsec(), - }) -} - -#[cfg(windows)] -fn playback_file_identity(file: &File) -> std::io::Result { - use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; - - #[link(name = "kernel32")] - extern "system" { - #[link_name = "GetFileInformationByHandle"] - fn get_file_information_by_handle( - file: std::os::windows::io::RawHandle, - information: *mut WindowsByHandleFileInformation, - ) -> i32; - } - - let mut information = MaybeUninit::::uninit(); - let result = unsafe { - get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) - }; - if result == 0 { - return Err(std::io::Error::last_os_error()); - } - let information = unsafe { information.assume_init() }; - Ok(PlaybackFileIdentity { - volume_serial_number: information.volume_serial_number, - file_index: ((information.file_index_high as u64) << 32) - | information.file_index_low as u64, - last_write_time: information.last_write_time, - }) -} - -#[cfg(not(any(unix, windows)))] -fn playback_file_identity(_file: &File) -> std::io::Result { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "native playback file identity is unsupported on this platform", - )) +#[derive(Clone, Debug)] +struct PlaybackFileAuthority { + source_path: PathBuf, + extension: String, + expected_size: u64, + source_identity: NativeFileIdentity, } #[derive(Clone, Debug)] struct PlaybackSourceAuthority { project_id: String, - source_path: PathBuf, - extension: String, - expected_size: u64, - source_identity: PlaybackFileIdentity, + full_mix: PlaybackFileAuthority, + stem_analysis_job_id: Option, + playable_stems: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlaybackRequestSource { + FullMix, + Stem(PlaybackStemKind), } /// Process-local authority for the one audio source currently admitted to the -/// rehearsal player. Replacing it revokes every previously selected source. +/// rehearsal player. Replacing it revokes the full mix, any in-flight stem +/// generation token, and every stem registered for the previous source. #[derive(Default)] pub struct PlaybackAuthority { current: Mutex>, } -/// Return the only renderer-visible handle for an app-minted playback project. +/// Return the renderer-visible full-mix handle for an app-minted playback project. pub fn playback_authority_uri(project_id: &str) -> Result { if !is_valid_project_id(project_id) { return Err("Could not prepare the selected audio for playback.".to_string()); @@ -145,6 +72,20 @@ pub fn playback_authority_uri(project_id: &str) -> Result { Ok(format!("{PLAYBACK_AUTHORITY_PREFIX}{project_id}")) } +/// Return an opaque generated-stem handle without exposing a native path. +pub fn playback_stem_authority_uri( + project_id: &str, + stem_kind: PlaybackStemKind, +) -> Result { + if !is_valid_project_id(project_id) { + return Err("Could not prepare generated stems for playback.".to_string()); + } + Ok(format!( + "{PLAYBACK_AUTHORITY_PREFIX}{project_id}/stem/{}", + stem_slug(stem_kind) + )) +} + impl PlaybackAuthority { /// Replace the current playback source with an already validated native /// source. The project id is app-minted and never derived from a path. @@ -155,7 +96,7 @@ impl PlaybackAuthority { let source_path = PathBuf::from(&source.source_path); let (file, _) = open_validated_source(&source_path, source.file_size_bytes) .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; - let source_identity = playback_file_identity(&file) + let source_identity = native_file_identity(&file) .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; let mut current = self .current @@ -163,26 +104,108 @@ impl PlaybackAuthority { .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; *current = Some(PlaybackSourceAuthority { project_id: project_id.to_string(), - source_path, - extension: source.extension.clone(), - expected_size: source.file_size_bytes, - source_identity, + full_mix: PlaybackFileAuthority { + source_path, + extension: source.extension.clone(), + expected_size: source.file_size_bytes, + source_identity, + }, + stem_analysis_job_id: None, + playable_stems: None, }); Ok(()) } - /// Serve GET/HEAD media requests only when their opaque project id still - /// names the current authority. Stale project ids fail closed immediately. + /// Mark a newly queued analysis as the only job allowed to register stems + /// for the current project and revoke any older generated stem set. + pub fn begin_stem_analysis(&self, project_id: &str, job_id: &str) -> Result<(), String> { + if !is_valid_project_id(project_id) || job_id.is_empty() { + return Err("Could not prepare generated stems for playback.".to_string()); + } + let mut current = self + .current + .lock() + .map_err(|_| "Could not prepare generated stems for playback.".to_string())?; + let authority = current + .as_mut() + .filter(|entry| entry.project_id == project_id) + .ok_or_else(|| "Could not prepare generated stems for playback.".to_string())?; + authority.stem_analysis_job_id = Some(job_id.to_string()); + authority.playable_stems = None; + Ok(()) + } + + /// Atomically bind a complete native-preflighted stem set to the current + /// project, but only if the same analysis job is still the latest owner. + /// + /// File identities come from the exact handles whose bytes passed hash/header + /// preflight. No producer path is accepted and partial registration is impossible. + pub fn activate_stems( + &self, + project_id: &str, + job_id: &str, + preflight: &PreflightPlayableStemSet, + ) -> Result<(), String> { + if !is_valid_project_id(project_id) || job_id.is_empty() { + return Err("Could not prepare generated stems for playback.".to_string()); + } + let mut sources = BTreeMap::new(); + for file in preflight.files() { + if sources + .insert( + file.stem_kind(), + PlaybackFileAuthority { + source_path: file.native_path().to_path_buf(), + extension: "wav".to_string(), + expected_size: file.file_size_bytes(), + source_identity: file.file_identity().clone(), + }, + ) + .is_some() + { + return Err("Could not prepare generated stems for playback.".to_string()); + } + } + if sources.len() != PlaybackStemKind::canonical_order().len() + || PlaybackStemKind::canonical_order() + .into_iter() + .any(|stem_kind| !sources.contains_key(&stem_kind)) + { + return Err("Could not prepare generated stems for playback.".to_string()); + } + + let mut current = self + .current + .lock() + .map_err(|_| "Could not prepare generated stems for playback.".to_string())?; + let authority = current + .as_mut() + .filter(|entry| entry.project_id == project_id) + .filter(|entry| entry.stem_analysis_job_id.as_deref() == Some(job_id)) + .ok_or_else(|| "Could not prepare generated stems for playback.".to_string())?; + authority.playable_stems = Some(sources); + Ok(()) + } + + /// Serve GET/HEAD media requests only when their opaque project/source token + /// still belongs to the current authority. Missing or revoked stems return 404. pub fn respond(&self, request: Request>) -> Response> { - let Some(project_id) = project_id_from_path(request.uri().path()) else { + let Some((project_id, requested_source)) = playback_request_source(request.uri().path()) else { return empty_response(StatusCode::NOT_FOUND); }; self.with_current_authority(project_id, |authority| { if request.method() != Method::GET && request.method() != Method::HEAD { - return empty_response(StatusCode::METHOD_NOT_ALLOWED); + return Some(empty_response(StatusCode::METHOD_NOT_ALLOWED)); } - serve_authorized_source(authority, &request) + let source = match requested_source { + PlaybackRequestSource::FullMix => &authority.full_mix, + PlaybackRequestSource::Stem(stem_kind) => { + authority.playable_stems.as_ref()?.get(&stem_kind)? + } + }; + Some(serve_authorized_source(source, &request)) }) + .flatten() .unwrap_or_else(|| empty_response(StatusCode::NOT_FOUND)) } @@ -202,12 +225,43 @@ impl PlaybackAuthority { } } -fn project_id_from_path(path: &str) -> Option<&str> { - let project_id = path.strip_prefix('/')?; - if project_id.is_empty() || project_id.contains('/') || project_id.contains('%') { +fn stem_slug(stem_kind: PlaybackStemKind) -> &'static str { + match stem_kind { + PlaybackStemKind::Vocals => "vocals", + PlaybackStemKind::Bass => "bass", + PlaybackStemKind::Drums => "drums", + PlaybackStemKind::Other => "other", + } +} + +fn stem_kind_from_slug(value: &str) -> Option { + match value { + "vocals" => Some(PlaybackStemKind::Vocals), + "bass" => Some(PlaybackStemKind::Bass), + "drums" => Some(PlaybackStemKind::Drums), + "other" => Some(PlaybackStemKind::Other), + _ => None, + } +} + +fn playback_request_source(path: &str) -> Option<(&str, PlaybackRequestSource)> { + let relative = path.strip_prefix('/')?; + if relative.is_empty() || relative.contains('%') { + return None; + } + let mut parts = relative.split('/'); + let project_id = parts.next()?; + if !is_valid_project_id(project_id) { return None; } - is_valid_project_id(project_id).then_some(project_id) + match (parts.next(), parts.next(), parts.next()) { + (None, None, None) => Some((project_id, PlaybackRequestSource::FullMix)), + (Some("stem"), Some(stem_slug), None) => Some(( + project_id, + PlaybackRequestSource::Stem(stem_kind_from_slug(stem_slug)?), + )), + _ => None, + } } fn content_type(extension: &str) -> Option<&'static str> { @@ -240,9 +294,9 @@ fn open_validated_source(source_path: &Path, expected_size: u64) -> Result<(File Ok((file, metadata.len())) } -fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), StatusCode> { +fn validated_file(authority: &PlaybackFileAuthority) -> Result<(File, u64), StatusCode> { let (file, len) = open_validated_source(&authority.source_path, authority.expected_size)?; - let current_identity = playback_file_identity(&file).map_err(|_| StatusCode::GONE)?; + let current_identity = native_file_identity(&file).map_err(|_| StatusCode::GONE)?; if current_identity != authority.source_identity { return Err(StatusCode::GONE); } @@ -250,7 +304,7 @@ fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), St } fn serve_authorized_source( - authority: &PlaybackSourceAuthority, + authority: &PlaybackFileAuthority, request: &Request>, ) -> Response> { let Some(media_type) = content_type(&authority.extension) else { @@ -371,8 +425,16 @@ fn empty_response(status: StatusCode) -> Response> { #[cfg(test)] mod tests { use super::*; + use bandscope_desktop::playable_stem_admission::preflight_playable_stem_set; + use bandscope_desktop_core::playable_stem_contract::PlayableStemArtifactSetReference; + use serde_json::json; use std::time::{SystemTime, UNIX_EPOCH}; + const STEM_ARTIFACT_SET_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SINGLE_SAMPLE_WAVE_SHA256: &str = + "4aebda3a657a0d8f532d11ceacb1679081d7bdf7d7d301a53f1096af3580be91"; + fn test_source(label: &str, bytes: &[u8]) -> (PathBuf, LocalAudioSourcePayload) { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -395,6 +457,58 @@ mod tests { (root, source) } + fn single_sample_wave() -> Vec { + vec![ + 0x52, 0x49, 0x46, 0x46, 0x26, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, 0x66, + 0x6d, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x40, 0x1f, + 0x00, 0x00, 0x80, 0x3e, 0x00, 0x00, 0x02, 0x00, 0x10, 0x00, 0x64, 0x61, 0x74, + 0x61, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + ] + } + + fn preflight_stem_fixture(label: &str) -> (PathBuf, PreflightPlayableStemSet) { + let (root, _) = test_source(label, b"full-mix"); + let set_root = root + .join("playable-stems-v1") + .join(STEM_ARTIFACT_SET_ID); + std::fs::create_dir_all(&set_root).expect("test stem set root should be created"); + let wave = single_sample_wave(); + for stem_kind in PlaybackStemKind::canonical_order() { + std::fs::write(set_root.join(stem_kind.file_name()), &wave) + .expect("test stem should be written"); + } + let stem_artifacts = PlaybackStemKind::canonical_order() + .into_iter() + .map(|stem_kind| { + json!({ + "artifactId": stem_kind.artifact_id(), + "stemKind": stem_slug(stem_kind), + "fileSizeBytes": wave.len(), + "contentHashSha256": SINGLE_SAMPLE_WAVE_SHA256, + "mediaType": "audio/wav", + "sampleRate": 8_000, + "channelCount": 1, + "sampleCount": 1, + "durationSeconds": 0.000125 + }) + }) + .collect::>(); + let reference = serde_json::from_value::(json!({ + "artifactSetId": STEM_ARTIFACT_SET_ID, + "formatVersion": 1, + "sampleRate": 8_000, + "channelCount": 1, + "sampleCount": 1, + "durationSeconds": 0.000125, + "appliedGain": 1.0, + "stemArtifacts": stem_artifacts + })) + .expect("test stem reference should satisfy the contract"); + let preflight = preflight_playable_stem_set(&root, &reference) + .expect("test stem files should pass native preflight"); + (root, preflight) + } + fn request(project_id: &str) -> Request> { Request::builder() .uri(format!("{PLAYBACK_SCHEME}://localhost/{project_id}")) @@ -402,12 +516,26 @@ mod tests { .expect("test request should build") } + fn stem_request(project_id: &str, stem_kind: PlaybackStemKind) -> Request> { + Request::builder() + .uri(format!( + "{PLAYBACK_SCHEME}://localhost/{project_id}/stem/{}", + stem_slug(stem_kind) + )) + .body(Vec::new()) + .expect("test stem request should build") + } + #[test] - fn renderer_handle_contains_only_the_app_minted_project_id() { + fn renderer_handles_contain_only_app_minted_ids_and_canonical_stem_tokens() { assert_eq!( playback_authority_uri("project-100-1").as_deref(), Ok("bandscope-project://project-100-1") ); + assert_eq!( + playback_stem_authority_uri("project-100-1", PlaybackStemKind::Vocals).as_deref(), + Ok("bandscope-project://project-100-1/stem/vocals") + ); assert!(playback_authority_uri("../../private.wav").is_err()); } @@ -440,6 +568,101 @@ mod tests { let _ = std::fs::remove_dir_all(second_root); } + #[test] + fn preflighted_stems_bind_and_serve_all_four_files_atomically() { + let (root, source) = test_source("stem-bind-full", b"full-mix"); + let (stem_root, preflight) = preflight_stem_fixture("stem-bind"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-125-1", &source) + .expect("full mix should activate"); + authority + .begin_stem_analysis("project-125-1", "job-10") + .expect("current analysis should own stem registration"); + authority + .activate_stems("project-125-1", "job-10", &preflight) + .expect("complete preflighted stems should bind"); + + for stem_kind in PlaybackStemKind::canonical_order() { + assert_eq!( + authority.respond(stem_request("project-125-1", stem_kind)).body(), + &single_sample_wave() + ); + } + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(stem_root); + } + + #[test] + fn older_same_project_job_cannot_overwrite_a_newer_stem_generation() { + let (root, source) = test_source("stem-race-full", b"full-mix"); + let (stem_root, preflight) = preflight_stem_fixture("stem-race"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-130-1", &source) + .expect("full mix should activate"); + authority + .begin_stem_analysis("project-130-1", "job-10") + .expect("first analysis should begin"); + authority + .begin_stem_analysis("project-130-1", "job-11") + .expect("newer analysis should supersede the first"); + + assert!(authority + .activate_stems("project-130-1", "job-10", &preflight) + .is_err()); + assert_eq!( + authority + .respond(stem_request("project-130-1", PlaybackStemKind::Vocals)) + .status(), + StatusCode::NOT_FOUND + ); + authority + .activate_stems("project-130-1", "job-11", &preflight) + .expect("latest analysis should bind stems"); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(stem_root); + } + + #[test] + fn stem_file_identity_rejects_replacement_after_preflight() { + let (root, source) = test_source("stem-identity-full", b"full-mix"); + let (stem_root, preflight) = preflight_stem_fixture("stem-identity"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-140-1", &source) + .expect("full mix should activate"); + authority + .begin_stem_analysis("project-140-1", "job-20") + .expect("analysis should begin"); + authority + .activate_stems("project-140-1", "job-20", &preflight) + .expect("preflighted stems should bind"); + + let vocals_path = preflight + .files() + .iter() + .find(|file| file.stem_kind() == PlaybackStemKind::Vocals) + .expect("vocals preflight should exist") + .native_path() + .to_path_buf(); + let replacement = vocals_path.with_extension("replacement"); + std::fs::write(&replacement, single_sample_wave()) + .expect("same-size replacement should be written"); + std::fs::remove_file(&vocals_path).expect("preflighted vocals should be removed"); + std::fs::rename(&replacement, &vocals_path) + .expect("replacement should occupy the preflighted path"); + + assert_eq!( + authority + .respond(stem_request("project-140-1", PlaybackStemKind::Vocals)) + .status(), + StatusCode::GONE + ); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(stem_root); + } + #[test] fn authorized_response_keeps_revocation_authority_until_use_finishes() { let (root, source) = test_source("linearizable-revocation", b"audio"); @@ -477,6 +700,13 @@ mod tests { .body(Vec::new()) .expect("test traversal request should build"); assert_eq!(authority.respond(traversal).status(), StatusCode::NOT_FOUND); + let unknown_stem = Request::builder() + .uri(format!( + "{PLAYBACK_SCHEME}://localhost/project-200-3/stem/private.wav" + )) + .body(Vec::new()) + .expect("unknown stem request should build"); + assert_eq!(authority.respond(unknown_stem).status(), StatusCode::NOT_FOUND); let _ = std::fs::remove_dir_all(root); } diff --git a/apps/desktop/src-tauri/src/playback_source_availability.rs b/apps/desktop/src-tauri/src/playback_source_availability.rs new file mode 100644 index 000000000..896695fe5 --- /dev/null +++ b/apps/desktop/src-tauri/src/playback_source_availability.rs @@ -0,0 +1,130 @@ +//! Renderer-safe source availability resolution for the current rehearsal project. +//! +//! Native playback authority remains the only owner of filesystem paths and file +//! identity. This module only decides whether an already-minted full mix and its +//! canonical four stems may be exposed together to the renderer. + +/// Generic buyer-safe error returned when native playback availability is not a +/// complete, current source set. +pub const PLAYBACK_SOURCE_AVAILABILITY_ERROR: &str = + "Could not read the current playback source availability."; + +/// Resolve one renderer-visible availability snapshot from native source probes. +/// +/// The full mix must remain available. Generated stems are either all present or +/// all absent; a partial set is rejected so the renderer never invents a mixed +/// generation or displays controls for sources that are not jointly authoritative. +pub fn resolve_playback_source_availability( + full_mix_authority: String, + stem_authorities: [String; 4], + mut probe: impl FnMut(&str) -> Result, +) -> Result, String> { + let full_mix_is_available = probe(&full_mix_authority) + .map_err(|_| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + if !full_mix_is_available { + return Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()); + } + + let mut available_stem_count = 0usize; + for stem_authority in &stem_authorities { + if probe(stem_authority) + .map_err(|_| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())? + { + available_stem_count += 1; + } + } + + if available_stem_count == 0 { + return Ok(vec![full_mix_authority]); + } + if available_stem_count != stem_authorities.len() { + return Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()); + } + + let mut available = Vec::with_capacity(1 + stem_authorities.len()); + available.push(full_mix_authority); + available.extend(stem_authorities); + Ok(available) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn authorities() -> (String, [String; 4]) { + ( + "bandscope-project://project-100-1".to_string(), + [ + "bandscope-project://project-100-1/stem/vocals".to_string(), + "bandscope-project://project-100-1/stem/bass".to_string(), + "bandscope-project://project-100-1/stem/drums".to_string(), + "bandscope-project://project-100-1/stem/other".to_string(), + ], + ) + } + + #[test] + fn full_mix_only_is_a_complete_availability_snapshot() { + let (full_mix, stems) = authorities(); + let full_mix_expected = full_mix.clone(); + let resolved = resolve_playback_source_availability(full_mix, stems, |authority| { + Ok(authority == full_mix_expected) + }) + .expect("full-mix-only authority should be visible"); + + assert_eq!(resolved, vec![full_mix_expected]); + } + + #[test] + fn complete_four_stem_set_is_exposed_in_canonical_order() { + let (full_mix, stems) = authorities(); + let expected = std::iter::once(full_mix.clone()) + .chain(stems.iter().cloned()) + .collect::>(); + let resolved = resolve_playback_source_availability(full_mix, stems, |_| Ok(true)) + .expect("complete authority set should be visible"); + + assert_eq!(resolved, expected); + } + + #[test] + fn partial_stem_availability_fails_closed() { + let (full_mix, stems) = authorities(); + let states = BTreeMap::from([ + (full_mix.clone(), true), + (stems[0].clone(), true), + (stems[1].clone(), false), + (stems[2].clone(), true), + (stems[3].clone(), true), + ]); + let resolved = resolve_playback_source_availability(full_mix, stems, |authority| { + Ok(*states.get(authority).unwrap_or(&false)) + }); + + assert_eq!( + resolved.as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + } + + #[test] + fn revoked_full_mix_and_probe_errors_fail_closed() { + let (full_mix, stems) = authorities(); + let revoked = resolve_playback_source_availability(full_mix.clone(), stems.clone(), |_| { + Ok(false) + }); + assert_eq!( + revoked.as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + + let probe_failure = resolve_playback_source_availability(full_mix, stems, |_| { + Err("native probe failed".to_string()) + }); + assert_eq!( + probe_failure.as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + } +} diff --git a/apps/desktop/src-tauri/src/playback_source_availability_command.rs b/apps/desktop/src-tauri/src/playback_source_availability_command.rs new file mode 100644 index 000000000..f94e907de --- /dev/null +++ b/apps/desktop/src-tauri/src/playback_source_availability_command.rs @@ -0,0 +1,168 @@ +//! Tauri IPC for renderer-safe rehearsal playback source discovery. +//! +//! The renderer supplies only the opaque full-mix authority it already owns. The +//! native process revalidates that authority against the current playback source +//! and returns only opaque handles that can still answer metadata probes. + +use crate::playback_protocol::{ + playback_authority_uri, playback_stem_authority_uri, PlaybackAuthority, + PLAYBACK_AUTHORITY_PREFIX, PLAYBACK_SCHEME, +}; +use bandscope_desktop::playback_source_availability::{ + resolve_playback_source_availability, PLAYBACK_SOURCE_AVAILABILITY_ERROR, +}; +use bandscope_desktop_core::{is_valid_project_id, playable_stem_contract::PlaybackStemKind}; +use std::sync::Arc; +use tauri::http::{Method, Request, StatusCode}; + +fn project_id_from_full_mix_authority(authority: &str) -> Result<&str, String> { + let project_id = authority + .strip_prefix(PLAYBACK_AUTHORITY_PREFIX) + .filter(|project_id| is_valid_project_id(project_id)) + .ok_or_else(|| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + if playback_authority_uri(project_id).as_deref() != Ok(authority) { + return Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()); + } + Ok(project_id) +} + +fn probe_playback_source( + playback_authority: &PlaybackAuthority, + authority: &str, +) -> Result { + let relative = authority + .strip_prefix(PLAYBACK_AUTHORITY_PREFIX) + .ok_or_else(|| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + let request = Request::builder() + .method(Method::HEAD) + .uri(format!("{PLAYBACK_SCHEME}://localhost/{relative}")) + .body(Vec::new()) + .map_err(|_| PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string())?; + + match playback_authority.respond(request).status() { + StatusCode::OK => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + _ => Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR.to_string()), + } +} + +fn current_playback_source_availability( + current_full_mix_authority: String, + playback_authority: &PlaybackAuthority, +) -> Result, String> { + let project_id = project_id_from_full_mix_authority(¤t_full_mix_authority)?; + let stem_authority_results = PlaybackStemKind::canonical_order() + .map(|stem_kind| playback_stem_authority_uri(project_id, stem_kind)); + let [vocals, bass, drums, other] = stem_authority_results; + let stem_authorities = [vocals?, bass?, drums?, other?]; + + resolve_playback_source_availability( + current_full_mix_authority, + stem_authorities, + |authority| probe_playback_source(playback_authority, authority), + ) +} + +/// Return the current full mix and, only when atomically available, all four +/// generated stem authorities. Native paths, hashes and file identities never +/// cross this IPC boundary. +#[tauri::command] +pub fn get_playback_source_availability( + current_full_mix_authority: String, + playback_authority: tauri::State<'_, Arc>, +) -> Result, String> { + current_playback_source_availability( + current_full_mix_authority, + playback_authority.inner().as_ref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use bandscope_desktop_core::LocalAudioSourcePayload; + use std::{path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; + + fn test_source() -> (PathBuf, LocalAudioSourcePayload) { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-source-availability-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("test root should be created"); + let path = root.join("source.wav"); + std::fs::write(&path, b"audio").expect("test source should be written"); + let canonical = path.canonicalize().expect("test source should canonicalize"); + ( + root, + LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: 5, + }, + ) + } + + #[test] + fn current_full_mix_is_discoverable_without_exposing_native_source_data() { + let (root, source) = test_source(); + let authority = PlaybackAuthority::default(); + authority + .activate("project-700-1", &source) + .expect("test source should activate"); + let handle = playback_authority_uri("project-700-1") + .expect("test project should mint an authority"); + + let available = current_playback_source_availability(handle.clone(), &authority) + .expect("current full mix should be discoverable"); + + assert_eq!(available, vec![handle]); + assert!(available.iter().all(|value| !value.contains(root.to_string_lossy().as_ref()))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stale_or_path_shaped_full_mix_authority_fails_closed() { + let (root, source) = test_source(); + let authority = PlaybackAuthority::default(); + authority + .activate("project-710-1", &source) + .expect("test source should activate"); + + for candidate in [ + "bandscope-project://project-709-1", + "bandscope-project://project-710-1/stem/vocals", + "file:///private/source.wav", + ] { + assert_eq!( + current_playback_source_availability(candidate.to_string(), &authority).as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR), + "{candidate} must not discover current native authority" + ); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn mutated_full_mix_is_not_reported_as_available() { + let (root, source) = test_source(); + let authority = PlaybackAuthority::default(); + authority + .activate("project-720-1", &source) + .expect("test source should activate"); + let handle = playback_authority_uri("project-720-1") + .expect("test project should mint an authority"); + std::fs::write(&source.source_path, b"other") + .expect("test source should be mutated in place"); + + assert_eq!( + current_playback_source_availability(handle, &authority).as_deref(), + Err(PLAYBACK_SOURCE_AVAILABILITY_ERROR) + ); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx index 5b8e6bb2b..765da69df 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx @@ -1,7 +1,10 @@ import { render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { RehearsalPlayer } from "./RehearsalPlayer"; +import { + isPlayableAudioSource, + RehearsalPlayer, +} from "./RehearsalPlayer"; const originalTauriInternals = Object.getOwnPropertyDescriptor( window, @@ -93,6 +96,38 @@ describe("RehearsalPlayer audio authority", () => { ); }); + it("converts only canonical generated-stem authority handles", () => { + const convertFileSrc = vi.fn( + (source: string) => `bandscope-playback://localhost/${source}`, + ); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + + for (const stem of ["vocals", "bass", "drums", "other"]) { + expect( + isPlayableAudioSource( + `bandscope-project://project-100-1/stem/${stem}`, + ), + ).toBe(true); + } + expect(convertFileSrc).toHaveBeenCalledWith( + "project-100-1/stem/vocals", + "bandscope-playback", + ); + expect( + isPlayableAudioSource( + "bandscope-project://project-100-1/stem/guitar", + ), + ).toBe(false); + expect( + isPlayableAudioSource( + "bandscope-project://project-100-1/stem/vocals/../private.wav", + ), + ).toBe(false); + }); + it("does not expose a native path when no current playback project authority exists", () => { const convertFileSrc = vi.fn(() => "asset://localhost/private-rehearsal.wav"); Object.defineProperty(window, "__TAURI_INTERNALS__", { diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.i18n.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.i18n.test.tsx new file mode 100644 index 000000000..ad4f4382a --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.i18n.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: vi.fn( + (source: string) => `bandscope-playback://localhost/${source}`, + ), + invoke: vi.fn(), +})); + +const fullMixAuthority = "bandscope-project://project-400-4"; +const stemAuthorities = [ + `${fullMixAuthority}/stem/vocals`, + `${fullMixAuthority}/stem/bass`, + `${fullMixAuthority}/stem/drums`, + `${fullMixAuthority}/stem/other`, +] as const; +const originalNavigatorLanguageDescriptor = Object.getOwnPropertyDescriptor( + navigator, + "language", +); + +describe("RehearsalPlayer playback-source locale copy", () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockClear(); + Object.defineProperty(navigator, "language", { + configurable: true, + value: "en-US", + }); + }); + + afterEach(() => { + if (originalNavigatorLanguageDescriptor === undefined) { + delete (navigator as { language?: string }).language; + return; + } + Object.defineProperty( + navigator, + "language", + originalNavigatorLanguageDescriptor, + ); + }); + + it("renders Korean playback-source copy while preserving opaque authority values", async () => { + Object.defineProperty(navigator, "language", { + configurable: true, + value: "ko-KR", + }); + const playbackSourceInvoke = vi.fn(async () => [ + fullMixAuthority, + ...stemAuthorities, + ]); + + render( + , + ); + + expect(await screen.findByRole("group", { name: "재생 소스" })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "전체 믹스" })).toHaveValue(fullMixAuthority); + expect(screen.getByRole("radio", { name: "보컬" })).toHaveValue(stemAuthorities[0]); + expect(screen.getByRole("radio", { name: "베이스" })).toHaveValue(stemAuthorities[1]); + expect(screen.getByRole("radio", { name: "드럼" })).toHaveValue(stemAuthorities[2]); + expect(screen.getByRole("radio", { name: "그 외 악기" })).toHaveValue(stemAuthorities[3]); + }); + + it("renders the Korean full-mix-only explanation", async () => { + Object.defineProperty(navigator, "language", { + configurable: true, + value: "ko-KR", + }); + const playbackSourceInvoke = vi.fn(async () => [fullMixAuthority]); + + render( + , + ); + + expect( + await screen.findByText( + "이 프로젝트에는 재생할 수 있는 스템이 없습니다. 전체 믹스는 바로 재생할 수 있습니다.", + { selector: '[role="status"]' }, + ), + ).toHaveAttribute("aria-atomic", "true"); + }); + + it("renders the Korean retry action after discovery failure", async () => { + Object.defineProperty(navigator, "language", { + configurable: true, + value: "ko-KR", + }); + const playbackSourceInvoke = vi + .fn() + .mockRejectedValueOnce(new Error("native failure")) + .mockResolvedValueOnce([fullMixAuthority, ...stemAuthorities]); + + render( + , + ); + + expect( + await screen.findByText( + "스템 소스를 확인하지 못했습니다. 전체 믹스는 계속 재생할 수 있습니다.", + { selector: '[role="status"]' }, + ), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "스템 소스 다시 확인" })); + + expect(await screen.findByRole("group", { name: "재생 소스" })).toBeInTheDocument(); + expect(playbackSourceInvoke).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx new file mode 100644 index 000000000..d6eadd142 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSelection.test.tsx @@ -0,0 +1,310 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: vi.fn( + (source: string) => `bandscope-playback://localhost/${source}`, + ), + invoke: vi.fn(), +})); + +const fullMixAuthority = "bandscope-project://project-100-1"; +const stemAuthorities = [ + `${fullMixAuthority}/stem/vocals`, + `${fullMixAuthority}/stem/bass`, + `${fullMixAuthority}/stem/drums`, + `${fullMixAuthority}/stem/other`, +] as const; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("RehearsalPlayer mounted playback-source selection", () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockClear(); + vi.mocked(invoke).mockReset(); + vi.mocked(invoke).mockResolvedValue([ + stemAuthorities[3], + fullMixAuthority, + stemAuthorities[1], + stemAuthorities[0], + stemAuthorities[2], + ]); + }); + + it("announces source discovery while stem availability is still pending", async () => { + const discovery = deferred(); + vi.mocked(invoke).mockReturnValueOnce(discovery.promise); + + render( + , + ); + + const discoveryStatus = await screen.findByText( + "Checking playback sources…", + { selector: '[role="status"]' }, + ); + expect(discoveryStatus).toHaveAttribute("aria-atomic", "true"); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + + discovery.resolve([fullMixAuthority, ...stemAuthorities]); + + expect(await screen.findByRole("group", { name: "Playback source" })).toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByText("Checking playback sources…", { + selector: '[role="status"]', + }), + ).not.toBeInTheDocument(), + ); + }); + + it("explains the full-mix-only state when native availability contains no stems", async () => { + vi.mocked(invoke).mockResolvedValueOnce([fullMixAuthority]); + + render( + , + ); + + expect( + await screen.findByText( + "No stem sources are available for this project. Full mix is ready.", + { selector: '[role="status"]' }, + ), + ).toHaveAttribute("aria-atomic", "true"); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + }); + + it("shows a retryable error when native source discovery fails", async () => { + vi.mocked(invoke) + .mockRejectedValueOnce(new Error("availability lookup failed")) + .mockResolvedValueOnce([fullMixAuthority, ...stemAuthorities]); + + render( + , + ); + + expect( + await screen.findByText( + "Could not check stem sources. Full mix is still available.", + { selector: '[role="status"]' }, + ), + ).toHaveAttribute("aria-atomic", "true"); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Check stem sources again" })); + + expect(await screen.findByRole("group", { name: "Playback source" })).toBeInTheDocument(); + expect(vi.mocked(invoke)).toHaveBeenCalledTimes(2); + expect( + screen.queryByText("Could not check stem sources. Full mix is still available."), + ).not.toBeInTheDocument(); + }); + + it("discovers the current atomic stem set and switches the mounted player only through opaque authority", async () => { + render( + , + ); + + expect(await screen.findByRole("group", { name: "Playback source" })).toBeInTheDocument(); + expect(vi.mocked(invoke)).toHaveBeenCalledWith( + "get_playback_source_availability", + { currentFullMixAuthority: fullMixAuthority }, + ); + + const fullMix = screen.getByRole("radio", { name: "Full mix" }); + const vocals = screen.getByRole("radio", { name: "Vocals" }); + expect(fullMix).toBeChecked(); + expect(vocals).not.toBeChecked(); + + fireEvent.click(vocals); + + await waitFor(() => expect(vocals).toBeChecked()); + expect(vi.mocked(convertFileSrc)).toHaveBeenCalledWith( + "project-100-1/stem/vocals", + "bandscope-playback", + ); + expect(vi.mocked(convertFileSrc)).not.toHaveBeenCalledWith( + expect.stringContaining("/private/"), + expect.anything(), + ); + }); + + it("revokes a failed selected stem immediately and refreshes native availability before allowing reselection", async () => { + const refresh = deferred(); + vi.mocked(invoke) + .mockResolvedValueOnce([fullMixAuthority, ...stemAuthorities]) + .mockReturnValueOnce(refresh.promise); + + render( + , + ); + + const vocals = await screen.findByRole("radio", { name: "Vocals" }); + fireEvent.click(vocals); + await waitFor(() => expect(vocals).toBeChecked()); + + fireEvent.error(screen.getByTestId("rehearsal-loop-audio")); + + await waitFor(() => expect(vi.mocked(invoke)).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + expect(screen.queryByRole("radio", { name: "Vocals" })).not.toBeInTheDocument(); + expect(vi.mocked(convertFileSrc)).toHaveBeenCalledWith( + "project-100-1", + "bandscope-playback", + ); + + refresh.resolve([fullMixAuthority]); + await waitFor(() => + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(), + ); + }); + + it("never renders partial native availability as buyer-selectable stems", async () => { + vi.mocked(invoke).mockResolvedValue([ + fullMixAuthority, + stemAuthorities[0], + stemAuthorities[1], + ]); + + render( + , + ); + + await waitFor(() => expect(vi.mocked(invoke)).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + expect(screen.queryByRole("radio", { name: "Vocals" })).not.toBeInTheDocument(); + }); + + it("does not let a late discovery result from the previous project repopulate the selector", async () => { + const first = deferred(); + const second = deferred(); + const nextFullMixAuthority = "bandscope-project://project-200-2"; + const nextStems = [ + `${nextFullMixAuthority}/stem/vocals`, + `${nextFullMixAuthority}/stem/bass`, + `${nextFullMixAuthority}/stem/drums`, + `${nextFullMixAuthority}/stem/other`, + ] as const; + vi.mocked(invoke) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const song = createDemoRehearsalSong(); + const { rerender } = render( + , + ); + + rerender( + , + ); + expect(screen.queryByRole("group", { name: "Playback source" })).not.toBeInTheDocument(); + + second.resolve([ + nextStems[2], + nextFullMixAuthority, + nextStems[0], + nextStems[3], + nextStems[1], + ]); + + const nextVocals = await screen.findByRole("radio", { name: "Vocals" }); + expect(nextVocals).toHaveValue(nextStems[0]); + + first.resolve([ + fullMixAuthority, + stemAuthorities[0], + stemAuthorities[1], + stemAuthorities[2], + stemAuthorities[3], + ]); + + await waitFor(() => expect(screen.getByRole("radio", { name: "Vocals" })).toHaveValue(nextStems[0])); + expect(screen.queryByDisplayValue(stemAuthorities[0])).not.toBeInTheDocument(); + }); + + it("keeps radio selection independent across separately mounted rehearsal players", async () => { + const secondFullMixAuthority = "bandscope-project://project-300-3"; + const secondStems = [ + `${secondFullMixAuthority}/stem/vocals`, + `${secondFullMixAuthority}/stem/bass`, + `${secondFullMixAuthority}/stem/drums`, + `${secondFullMixAuthority}/stem/other`, + ] as const; + vi.mocked(invoke).mockImplementation(async (_command, args) => { + const current = args?.currentFullMixAuthority; + return current === secondFullMixAuthority + ? [secondFullMixAuthority, ...secondStems] + : [fullMixAuthority, ...stemAuthorities]; + }); + + const song = createDemoRehearsalSong(); + render( + <> + + + , + ); + + const fullMixRadios = await screen.findAllByRole("radio", { name: "Full mix" }); + const vocalsRadios = screen.getAllByRole("radio", { name: "Vocals" }); + expect(fullMixRadios[0]).toBeChecked(); + expect(fullMixRadios[1]).toBeChecked(); + + fireEvent.click(vocalsRadios[1]); + + await waitFor(() => expect(vocalsRadios[1]).toBeChecked()); + expect(fullMixRadios[0]).toBeChecked(); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSwitch.integration.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSwitch.integration.test.tsx new file mode 100644 index 000000000..ffd90b24c --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.sourceSwitch.integration.test.tsx @@ -0,0 +1,223 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: vi.fn( + (source: string) => `bandscope-playback://localhost/${source}`, + ), + invoke: vi.fn(), +})); + +const fullMixAuthority = "bandscope-project://project-100-1"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const stemAuthorities = [ + vocalsAuthority, + `${fullMixAuthority}/stem/bass`, + `${fullMixAuthority}/stem/drums`, + `${fullMixAuthority}/stem/other`, +] as const; + +function admitDuration(audio: HTMLAudioElement, duration: number): void { + Object.defineProperty(audio, "duration", { + configurable: true, + value: duration, + }); + fireEvent.loadedMetadata(audio); +} + +async function enterLoopingPlayback(audio: HTMLAudioElement): Promise { + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: /start the count-in/i })); + await act(async () => { + vi.advanceTimersByTime(2_100); + await Promise.resolve(); + }); + audio.currentTime = 17.5; + fireEvent.timeUpdate(audio); +} + +describe("RehearsalPlayer mounted source-switch transaction", () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockClear(); + vi.mocked(invoke).mockReset(); + vi.mocked(invoke).mockResolvedValue([ + fullMixAuthority, + ...stemAuthorities, + ]); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("keeps a looping transport non-playing until the selected stem is admitted, then restores position and resumes", async () => { + const load = vi + .spyOn(HTMLMediaElement.prototype, "load") + .mockImplementation(() => undefined); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); + + render( + , + ); + + const vocals = await screen.findByRole("radio", { name: "Vocals" }); + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 120); + await enterLoopingPlayback(audio); + play.mockClear(); + load.mockClear(); + + fireEvent.click(vocals); + + expect(vocals).toBeChecked(); + expect(load).toHaveBeenCalledTimes(1); + expect(play).not.toHaveBeenCalled(); + + admitDuration(audio, 120); + + expect(audio.currentTime).toBe(17.5); + expect(play).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + play.mockClear(); + fireEvent.loadedMetadata(audio); + expect(play).not.toHaveBeenCalled(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("ignores a stale play rejection from the prior source while target metadata is pending", async () => { + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined); + let rejectPriorPlay: ((reason?: unknown) => void) | undefined; + const priorPlay = new Promise((_resolve, reject) => { + rejectPriorPlay = reject; + }); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockImplementationOnce(() => priorPlay) + .mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); + + render( + , + ); + + const vocals = await screen.findByRole("radio", { name: "Vocals" }); + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 120); + await enterLoopingPlayback(audio); + expect(play).toHaveBeenCalledTimes(1); + + fireEvent.click(vocals); + expect(play).toHaveBeenCalledTimes(1); + + await act(async () => { + rejectPriorPlay?.( + Object.assign(new Error("prior source rejected after replacement"), { + name: "NotSupportedError", + }), + ); + await Promise.resolve(); + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + admitDuration(audio, 120); + expect(audio.currentTime).toBe(17.5); + expect(play).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("aborts a target that cannot cover the active loop and never reuses its receipt", async () => { + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); + + render( + , + ); + + const vocals = await screen.findByRole("radio", { name: "Vocals" }); + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 120); + await enterLoopingPlayback(audio); + play.mockClear(); + + fireEvent.click(vocals); + expect(play).not.toHaveBeenCalled(); + + admitDuration(audio, 20); + + expect(play).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toMatch( + /could not play this local audio/i, + ); + + admitDuration(audio, 120); + expect(play).not.toHaveBeenCalled(); + }); + + it("remounts transport authority on project rotation and keeps the new project non-playing after admission", async () => { + const load = vi + .spyOn(HTMLMediaElement.prototype, "load") + .mockImplementation(() => undefined); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); + + const song = createDemoRehearsalSong(); + const { rerender } = render( + , + ); + + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 120); + await enterLoopingPlayback(audio); + play.mockClear(); + load.mockClear(); + + const nextProjectAuthority = "bandscope-project://project-200-1"; + rerender( + , + ); + + const rotatedAudio = screen.getByTestId( + "rehearsal-loop-audio", + ) as HTMLAudioElement; + expect(rotatedAudio).not.toBe(audio); + expect(load).toHaveBeenCalledTimes(1); + expect(play).not.toHaveBeenCalled(); + + admitDuration(rotatedAudio, 120); + expect(play).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 14d582265..e30a02c1d 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -1,1188 +1,286 @@ import { useCallback, useEffect, + useId, useMemo, useRef, useState, - type ChangeEvent, - type FocusEvent, - type KeyboardEvent as ReactKeyboardEvent, + type ComponentProps, type ReactElement, + type SyntheticEvent, } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { detectPreferredLocale } from "../../i18n"; import { - MAX_SECTION_TIME_SECONDS, - type RehearsalSong, -} from "@bandscope/shared-types"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { Button } from "@/components/ui/button"; + RehearsalPlayer as RehearsalPlayerCore, +} from "./RehearsalPlayerCore"; +import { createPlaybackSourceCopy } from "./playbackSourceCopy"; import { - createTranslator, - detectPreferredLocale, - type TranslationKey, -} from "../../i18n"; + discoverPlaybackSourceOutcome, + type PlaybackSourceInvoke, +} from "./playbackSourceDiscovery"; import { - createRehearsalCountInClickEngine, - type RehearsalCountInClickEngine, -} from "./rehearsalCountInClick"; -import { - beatDurationMs, - createIdleTransportState, - fillRehearsalCopy, - formatRehearsalClock, - nextActionTemplateKey, - nextActionValues, - isRehearsalPlaybackRate, - rehearsalPlaybackRates, - reduceRehearsalTransport, - resolveLoopWindows, - type RehearsalLoopWindow, - type RehearsalTransportState, -} from "./rehearsalTransport"; + beginPlaybackSourceDiscovery, + completePlaybackSourceDiscovery, + createPlaybackSourceSession, + selectPlaybackSource, + type PlaybackSourceSession, +} from "./playbackSourceSession"; +import type { PlaybackSourceKind } from "./playbackSourceSelection"; -interface RehearsalPlayerProps { - song: RehearsalSong; - onSongUpdate?: (song: RehearsalSong) => void; - onSelectedSectionIndexChange?: (sectionIndex: number | null) => void; - sectionSelectionRequest?: { - sectionIndex: number; - requestId: number; - } | null; - hasLocalAudio?: boolean; - audioSourcePath?: string | null; - activeRole?: string | null; - activeRoleName?: string | null; - startNonce?: number; -} +export { isPlayableAudioSource } from "./RehearsalPlayerCore"; -const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://"; -const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/; +type RehearsalPlayerCoreProps = ComponentProps; -/** Convert an opaque current-project authority into BandScope's native media URL. */ -function resolveAudioSourceUrl( - sourcePath: string | null | undefined, -): string | null { - if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) { - return null; - } - const projectId = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); - if (!PLAYBACK_PROJECT_ID.test(projectId)) { - return null; - } - try { - return convertFileSrc(projectId, "bandscope-playback"); - } catch { - return null; - } -} +type PlaybackSourceFeedback = { + fullMixAuthority: string; + status: "empty" | "error"; +}; -/** Return whether a source authority can be converted into a playable native URL. */ -export function isPlayableAudioSource( - sourcePath: string | null | undefined, -): boolean { - return resolveAudioSourceUrl(sourcePath) !== null; -} +export type RehearsalPlayerProps = RehearsalPlayerCoreProps & { + /** Test seam for the renderer-safe Tauri availability command. */ + playbackSourceInvoke?: PlaybackSourceInvoke; +}; -/** Return the displayed map-clock progress for the current loop. */ -function loopProgressPercent(state: RehearsalTransportState): number { - if (!state.loop) { - return 0; - } - const duration = state.loop.endSeconds - state.loop.startSeconds; - if (!(duration > 0)) { - return 0; - } - return Math.min( - 100, - Math.max( - 0, - ((state.playheadSeconds - state.loop.startSeconds) / duration) * 100, - ), - ); -} +const PLAYBACK_SOURCE_COPY_KEY = { + full_mix: "fullMix", + vocals: "vocals", + bass: "bass", + drums: "drums", + other: "other", +} as const satisfies Readonly>; -/** Return whether two loop windows describe the same transport timing authority. */ -function hasSameLoopTiming( - current: RehearsalLoopWindow, - next: RehearsalLoopWindow, -): boolean { - return ( - current.selectionKey === next.selectionKey && - current.sectionId === next.sectionId && - current.startSeconds === next.startSeconds && - current.endSeconds === next.endSeconds && - current.tempoBpm === next.tempoBpm && - current.countInBeats === next.countInBeats - ); -} - -/** Return a stable selection key when analysis emits duplicate section IDs. */ -function loopSelectionKey(loop: RehearsalLoopWindow): string { - return loop.selectionKey; -} - -/** Return whether a selected loop is fully covered by admitted local media. */ -function loopFitsAdmittedMedia( - loop: RehearsalLoopWindow, - mediaDurationSeconds: number | null, -): boolean { - return ( - mediaDurationSeconds !== null && - Number.isFinite(mediaDurationSeconds) && - mediaDurationSeconds > 0 && - loop.startSeconds < mediaDurationSeconds && - loop.endSeconds <= mediaDurationSeconds - ); +function commitPlaybackSourceSession( + sessionRef: { current: PlaybackSourceSession }, + setSession: (next: PlaybackSourceSession) => void, + next: PlaybackSourceSession, +): void { + sessionRef.current = next; + setSession(next); } -/** Render tonight's first section loop with a count-in and a named next action. */ +/** + * Bind renderer-safe source discovery to the mounted rehearsal player. + * + * This wrapper owns only option discovery/selection. The existing player remains + * the transport owner and receives exactly one opaque current source authority. + */ export function RehearsalPlayer({ - song, - onSongUpdate, - onSelectedSectionIndexChange, - sectionSelectionRequest = null, - hasLocalAudio = false, + playbackSourceInvoke, audioSourcePath = null, - activeRole = null, - activeRoleName = null, - startNonce = 0, + hasLocalAudio = false, + ...coreProps }: RehearsalPlayerProps): ReactElement { - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const playableLoops = useMemo( - () => resolveLoopWindows(song, activeRole), - [activeRole, song], - ); - const [selectedLoopKey, setSelectedLoopKey] = useState(null); - const lastHandledSectionSelectionRequestId = useRef(0); - const [boundaryError, setBoundaryError] = useState(false); - const selectedLoop = - playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? - playableLoops[0] ?? - null; - useEffect(() => { - onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null); - }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]); - useEffect(() => { - if (!sectionSelectionRequest) { - return; - } - const { requestId, sectionIndex } = sectionSelectionRequest; - if ( - !Number.isSafeInteger(requestId) || - requestId <= lastHandledSectionSelectionRequestId.current - ) { - return; - } - lastHandledSectionSelectionRequestId.current = requestId; - if ( - !Number.isSafeInteger(sectionIndex) || - sectionIndex < 0 || - sectionIndex >= song.sections.length - ) { - return; - } - const requestedLoop = playableLoops.find( - (loop) => loop.sourceIndex === sectionIndex, - ); - if (!requestedLoop) { - return; - } - setSelectedLoopKey(loopSelectionKey(requestedLoop)); - }, [playableLoops, sectionSelectionRequest, song.sections.length]); - const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; - const [boundaryDraft, setBoundaryDraft] = useState(() => ({ - end: selectedLoop ? String(selectedLoop.endSeconds) : "", - start: selectedLoop ? String(selectedLoop.startSeconds) : "", - })); - useEffect(() => { - setBoundaryError(false); - setBoundaryDraft({ - end: selectedLoop ? String(selectedLoop.endSeconds) : "", - start: selectedLoop ? String(selectedLoop.startSeconds) : "", - }); - }, [selectedBoundaryKey, selectedLoop?.endSeconds, selectedLoop?.startSeconds]); - const handleSectionKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { - return; - } - const focusedIndex = Number(event.currentTarget.dataset.loopIndex); - const selectedIndex = selectedLoop - ? playableLoops.indexOf(selectedLoop) - : -1; - const currentIndex = - Number.isSafeInteger(focusedIndex) && - focusedIndex >= 0 && - focusedIndex < playableLoops.length - ? focusedIndex - : selectedIndex; - const nextIndex = - currentIndex + (event.key === "ArrowRight" ? 1 : -1); - if ( - currentIndex < 0 || - nextIndex < 0 || - nextIndex >= playableLoops.length - ) { - return; - } - event.preventDefault(); - const nextLoop = playableLoops[nextIndex]; - setSelectedLoopKey(loopSelectionKey(nextLoop)); - document - .getElementById( - `rehearsal-loop-section-${loopSelectionKey(nextLoop)}-${nextIndex}`, - ) - ?.focus(); - }, - [playableLoops, selectedLoop], - ); - const [transport, setTransport] = useState(() => - reduceRehearsalTransport(createIdleTransportState(), { - type: "arm", - loop: playableLoops[0] ?? null, - }), - ); - const countInClickEngine = useMemo( - () => createRehearsalCountInClickEngine(), + const sourceGroupName = useId(); + const playbackSourceCopy = useMemo( + () => createPlaybackSourceCopy(detectPreferredLocale()), [], ); - const lastHandledStartNonce = useRef(0); - const restartAudioOnLoopRef = useRef(false); - const lastCountInClickKeyRef = useRef(null); - const countInBeatRef = useRef<{ - durationMs: number; - startedAt: number; - remainingBeats: number; - progress: number; - } | null>(null); - const audioRef = useRef(null); - const playbackIntentRef = useRef<"active" | "inactive">("inactive"); - const playRequestSequenceRef = useRef(0); - const audioSourceUrl = useMemo( - () => resolveAudioSourceUrl(audioSourcePath), - [audioSourcePath], - ); - const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null; - const hasNativeAudioConversionError = Boolean( - hasLocalAudio && - audioSourcePath && - !audioSourcePath.startsWith("browser://") && - audioSourceUrl === null, + const invokePlaybackSource = useMemo( + () => + playbackSourceInvoke ?? + ((command, args) => invoke(command, args)), + [playbackSourceInvoke], ); - const [playbackError, setPlaybackError] = useState(false); - const [mediaDurationSeconds, setMediaDurationSeconds] = useState( - null, - ); - - useEffect( - () => () => { - void countInClickEngine.dispose(); - }, - [countInClickEngine], + const [sourceSession, setSourceSession] = useState(() => + createPlaybackSourceSession(hasLocalAudio ? audioSourcePath : null), ); + const [sourceDiscoveryFeedback, setSourceDiscoveryFeedback] = + useState(null); + const sourceSessionRef = useRef(sourceSession); + const discoveryGenerationRef = useRef(0); - const handlePlaybackError = useCallback(() => { - playbackIntentRef.current = "inactive"; - setPlaybackError(true); - setTransport((current) => { - if (current.phase === "idle" || current.phase === "armed") { - return current; - } - return reduceRehearsalTransport(current, { type: "stop" }); - }); - }, []); - - const handlePlayRejection = useCallback( - (error: unknown, requestSequence: number) => { - if (requestSequence !== playRequestSequenceRef.current) { + const discoverCurrentPlaybackSources = useCallback( + ( + baseSession: PlaybackSourceSession, + currentFullMixAuthority: string | null, + ): void => { + const generation = ++discoveryGenerationRef.current; + setSourceDiscoveryFeedback(null); + const started = beginPlaybackSourceDiscovery( + baseSession, + currentFullMixAuthority, + ); + commitPlaybackSourceSession( + sourceSessionRef, + setSourceSession, + started.state, + ); + if (started.request === null) { return; } - const expectedInterruption = - playbackIntentRef.current === "inactive" && - typeof error === "object" && - error !== null && - "name" in error && - error.name === "AbortError"; - if (!expectedInterruption) { - handlePlaybackError(); - } - }, - [handlePlaybackError], - ); - const startAudio = useCallback( - (loop: RehearsalLoopWindow, resume: boolean) => { - const audio = audioRef.current; - if (!audio || !audioSourceUrl) { - handlePlaybackError(); - return; - } - try { - restartAudioOnLoopRef.current = !resume; - if (!resume) { - audio.currentTime = loop.startSeconds; - audio.volume = 0; - } else { - audio.volume = 1; - } - playbackIntentRef.current = "active"; - const requestSequence = ++playRequestSequenceRef.current; - const playPromise = audio.play(); - if (playPromise) { - void playPromise.catch((error: unknown) => - handlePlayRejection(error, requestSequence), - ); + const request = started.request; + void discoverPlaybackSourceOutcome( + request.fullMixAuthority, + invokePlaybackSource, + ).then((outcome) => { + if (generation !== discoveryGenerationRef.current) { + return; } - } catch { - handlePlaybackError(); - } + const completed = completePlaybackSourceDiscovery( + sourceSessionRef.current, + request, + outcome.options, + ); + commitPlaybackSourceSession( + sourceSessionRef, + setSourceSession, + completed, + ); + setSourceDiscoveryFeedback( + outcome.status === "ready" + ? null + : { + fullMixAuthority: request.fullMixAuthority, + status: outcome.status, + }, + ); + }); }, - [audioSourceUrl, handlePlaybackError, handlePlayRejection], + [invokePlaybackSource], ); useEffect(() => { - const audio = audioRef.current; - if (!audio) { - return undefined; - } - playbackIntentRef.current = "inactive"; - setMediaDurationSeconds(null); - if (!audio.paused) { - audio.pause(); - } - audio.volume = 1; - if (audioSourceUrl) { - audio.src = audioSourceUrl; - audio.load(); - } else { - audio.removeAttribute("src"); - } - setPlaybackError(hasNativeAudioConversionError); + const currentFullMixAuthority = hasLocalAudio ? audioSourcePath : null; + discoverCurrentPlaybackSources( + createPlaybackSourceSession(currentFullMixAuthority), + currentFullMixAuthority, + ); return () => { - playbackIntentRef.current = "inactive"; - if (!audio.paused) { - audio.pause(); - } + discoveryGenerationRef.current += 1; }; - }, [audioSourceUrl, hasNativeAudioConversionError]); + }, [audioSourcePath, discoverCurrentPlaybackSources, hasLocalAudio]); - /** Admit only a finite positive duration from the currently loaded local source. */ - const handleLoadedMetadata = useCallback(() => { - const duration = audioRef.current?.duration ?? Number.NaN; - setMediaDurationSeconds( - Number.isFinite(duration) && duration > 0 ? duration : null, - ); + const choosePlaybackSource = useCallback((authority: string) => { + const selected = selectPlaybackSource(sourceSessionRef.current, authority); + commitPlaybackSourceSession(sourceSessionRef, setSourceSession, selected); }, []); - useEffect(() => { - setTransport((current) => { - if ( - current.loop && - selectedLoop && - hasSameLoopTiming(current.loop, selectedLoop) - ) { - if ( - current.loop.sectionLabel === selectedLoop.sectionLabel && - current.loop.tempoAssumed === selectedLoop.tempoAssumed && - current.loop.sourceIndex === selectedLoop.sourceIndex - ) { - return current; - } - return { ...current, loop: selectedLoop }; - } - return reduceRehearsalTransport(current, { - type: "arm", - loop: selectedLoop, - }); - }); - }, [selectedLoop]); - - useEffect(() => { - const audio = audioRef.current; - if (!audio) { - return; - } - try { - audio.playbackRate = transport.playbackRate; - if ("preservesPitch" in audio) { - audio.preservesPitch = true; - } - } catch { - handlePlaybackError(); - } - }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]); - - useEffect(() => { - if (startNonce <= lastHandledStartNonce.current) { - return; - } - if (!hasPlayableAudio || !selectedLoop || mediaDurationSeconds === null) { - return; - } - if (!loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)) { - lastHandledStartNonce.current = startNonce; - return; - } - lastHandledStartNonce.current = startNonce; - setPlaybackError(false); - startAudio(selectedLoop, false); - setTransport((current) => { - const armed = reduceRehearsalTransport(current, { - type: "arm", - loop: selectedLoop, - }); - return reduceRehearsalTransport(armed, { type: "start" }); - }); - }, [ - startAudio, - startNonce, - hasPlayableAudio, - mediaDurationSeconds, - selectedLoop, - ]); - - useEffect(() => { - const transportLoopCovered = - transport.loop === null || - loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds); - if (hasPlayableAudio && transportLoopCovered) { - return; - } - playbackIntentRef.current = "inactive"; - setTransport((current) => { - if (current.phase === "idle" || current.phase === "armed") { - return current; - } - return reduceRehearsalTransport(current, { type: "stop" }); - }); - }, [hasPlayableAudio, mediaDurationSeconds, transport.loop]); - - useEffect(() => { - if (transport.phase !== "counting-in" || !transport.loop) { - countInBeatRef.current = null; - lastCountInClickKeyRef.current = null; - countInClickEngine.stop(); - return undefined; - } - const durationMs = - beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate; - const now = performance.now(); - const previous = countInBeatRef.current; - const sameBeat = - previous?.remainingBeats === transport.countInRemainingBeats; - const elapsedProgress = sameBeat - ? Math.max(0, now - previous.startedAt) / previous.durationMs - : 0; - const progress = sameBeat - ? Math.min(1, previous.progress + elapsedProgress) - : 0; - const currentClickKey = `${transport.loop.selectionKey}:${transport.countInRemainingBeats}`; + const retryPlaybackSourceDiscovery = useCallback((): void => { + const current = sourceSessionRef.current; if ( - countInClickEngine.available && - lastCountInClickKeyRef.current !== currentClickKey - ) { - lastCountInClickKeyRef.current = currentClickKey; - void countInClickEngine - .click(transport.countInRemainingBeats === transport.loop.countInBeats) - .catch(() => { - // Count-in click failure must not gain authority over admitted song playback. - }); - } - countInBeatRef.current = { - durationMs, - startedAt: now, - remainingBeats: transport.countInRemainingBeats, - progress, - }; - let timer: number | undefined; - /** Schedule the next count-in beat without coupling it to React commits. */ - const scheduleBeat = (delayMs: number) => { - timer = window.setTimeout(() => { - const current = countInBeatRef.current; - if (!current || current.remainingBeats <= 0) { - return; - } - current.remainingBeats -= 1; - current.progress = 0; - setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); - if (current.remainingBeats > 0) { - const nextClickKey = `${transport.loop?.selectionKey ?? ""}:${current.remainingBeats}`; - if ( - countInClickEngine.available && - lastCountInClickKeyRef.current !== nextClickKey - ) { - lastCountInClickKeyRef.current = nextClickKey; - void countInClickEngine.click(false).catch(() => { - // The transport remains authoritative when Web Audio is unavailable. - }); - } - current.startedAt = performance.now(); - scheduleBeat(current.durationMs); - } - }, delayMs); - }; - scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); - return () => { - if (timer !== undefined) { - window.clearTimeout(timer); - } - }; - }, [ - countInClickEngine, - transport.loop, - transport.phase, - transport.playbackRate, - ]); - - useEffect(() => { - if (!audioSourceUrl || !transport.loop) { - return undefined; - } - const audio = audioRef.current; - if (!audio) { - return undefined; - } - if (transport.phase === "looping") { - try { - if (restartAudioOnLoopRef.current) { - audio.currentTime = transport.loop.startSeconds; - restartAudioOnLoopRef.current = false; - } - audio.volume = 1; - playbackIntentRef.current = "active"; - const requestSequence = ++playRequestSequenceRef.current; - const playPromise = audio.play(); - if (playPromise) { - void playPromise.catch((error: unknown) => - handlePlayRejection(error, requestSequence), - ); - } - } catch { - handlePlaybackError(); - } - } else if ( - transport.phase === "armed" || - transport.phase === "paused" || - transport.phase === "idle" + current.fullMixAuthority === null || + current.fullMixAuthority !== audioSourcePath ) { - playbackIntentRef.current = "inactive"; - if (!audio.paused) { - audio.pause(); - } - audio.volume = 1; - } - return undefined; - }, [ - audioSourceUrl, - handlePlaybackError, - handlePlayRejection, - transport.phase, - transport.loop, - ]); - - useEffect(() => { - if (!audioSourceUrl || transport.phase !== "looping" || !transport.loop) { - return undefined; - } - const audio = audioRef.current; - if (!audio) { - return undefined; + return; } - const loop = transport.loop; - const playbackRate = transport.playbackRate; - let boundaryTimer: number | undefined; - /** Cancel the pending media-clock boundary check. */ - const clearBoundaryTimer = () => { - if (boundaryTimer !== undefined) { - window.clearTimeout(boundaryTimer); - boundaryTimer = undefined; - } - }; - /** Restart media at the exact selected section boundary. */ - const restartLoop = () => { - try { - audio.currentTime = loop.startSeconds; - playbackIntentRef.current = "active"; - const requestSequence = ++playRequestSequenceRef.current; - const playPromise = audio.play(); - if (playPromise) { - void playPromise.catch((error: unknown) => - handlePlayRejection(error, requestSequence), - ); - } - } catch { - handlePlaybackError(); - return; - } - scheduleLoopBoundary(); - }; - /** Schedule a media-clock boundary check and reschedule if timers fire early. */ - const scheduleLoopBoundary = () => { - clearBoundaryTimer(); - const remainingSeconds = loop.endSeconds - audio.currentTime; - if (!Number.isFinite(remainingSeconds)) { - return; - } - if (remainingSeconds <= 0) { - restartLoop(); - return; - } - boundaryTimer = window.setTimeout(() => { - boundaryTimer = undefined; - if (audio.currentTime >= loop.endSeconds) { - restartLoop(); - } else { - scheduleLoopBoundary(); - } - }, - Math.min( - (remainingSeconds / playbackRate) * 1000, - 2_147_483_647, - ), - ); - }; - /** Keep the map playhead aligned with the scoped audio element. */ - const syncPlayhead = () => { - if (audio.currentTime >= loop.endSeconds) { - restartLoop(); - } else { - scheduleLoopBoundary(); - } - setTransport((current) => - reduceRehearsalTransport(current, { - type: "sync", - playheadSeconds: audio.currentTime, - }), - ); - }; - /** Stop the transport when the media element reports a real playback error. */ - const failPlayback = () => handlePlaybackError(); - audio.addEventListener("timeupdate", syncPlayhead); - audio.addEventListener("error", failPlayback); - audio.addEventListener("ended", restartLoop); - scheduleLoopBoundary(); - return () => { - clearBoundaryTimer(); - audio.removeEventListener("timeupdate", syncPlayhead); - audio.removeEventListener("error", failPlayback); - audio.removeEventListener("ended", restartLoop); - }; - }, [ - audioSourceUrl, - handlePlaybackError, - handlePlayRejection, - transport.phase, - transport.loop, - transport.playbackRate, - ]); - - const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); - const nextAction = - activeRoleName && playableLoops.length === 0 - ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { - roleName: activeRoleName, - }) - : fillRehearsalCopy( - t(actionKey as TranslationKey), - nextActionValues(transport), - ); - const sectionPickerLabel = activeRoleName - ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { - roleName: activeRoleName, - }) - : t("workspaceLoopSectionPickerLabel"); - const canStart = - transport.loop !== null && - hasPlayableAudio && - loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && - (transport.phase === "armed" || transport.phase === "paused"); - const canPause = - transport.phase === "counting-in" || transport.phase === "looping"; - const canStop = transport.phase !== "idle" && transport.loop !== null; - const startLabel = - transport.phase === "paused" - ? t("workspaceLoopResume") - : t("workspaceLoopStart"); - const handleBoundaryBlur = useCallback( - (boundary: "start" | "end", event: FocusEvent) => { - if (!selectedLoop || !onSongUpdate) { - return; - } - const rawValue = event.currentTarget.value.trim(); - const value = Number(rawValue); - const withinLoadedMedia = - !hasPlayableAudio || - (mediaDurationSeconds !== null && - (boundary === "start" - ? value < mediaDurationSeconds - : value <= mediaDurationSeconds)); - const valid = - rawValue !== "" && - Number.isSafeInteger(value) && - value >= 0 && - value <= MAX_SECTION_TIME_SECONDS && - withinLoadedMedia && - (boundary === "start" - ? value < selectedLoop.endSeconds - : value > selectedLoop.startSeconds); - if (!valid) { - const currentValue = - boundary === "start" - ? selectedLoop.startSeconds - : selectedLoop.endSeconds; - setBoundaryDraft((current) => ({ - ...current, - [boundary]: String(currentValue), - })); - setBoundaryError(true); - return; - } + discoverCurrentPlaybackSources(current, current.fullMixAuthority); + }, [audioSourcePath, discoverCurrentPlaybackSources]); - setBoundaryError(false); - const currentValue = - boundary === "start" - ? selectedLoop.startSeconds - : selectedLoop.endSeconds; - if (value === currentValue) { - setBoundaryDraft((current) => ({ - ...current, - [boundary]: String(currentValue), - })); + const handlePlaybackSourceErrorCapture = useCallback( + (event: SyntheticEvent): void => { + if (!(event.target instanceof HTMLMediaElement)) { return; } - - const sectionIndex = selectedLoop.sourceIndex; - const section = song.sections[sectionIndex]; + const current = sourceSessionRef.current; if ( - !section || - section.id !== selectedLoop.sectionId || - section.timeRange.start !== selectedLoop.startSeconds || - section.timeRange.end !== selectedLoop.endSeconds + current.fullMixAuthority === null || + current.fullMixAuthority !== audioSourcePath || + current.selectedAuthority === null || + current.selectedAuthority === current.fullMixAuthority ) { - setBoundaryDraft({ - end: String(selectedLoop.endSeconds), - start: String(selectedLoop.startSeconds), - }); - setBoundaryError(true); return; } - const nextSong = { - ...song, - sections: song.sections.map((currentSection, index) => - index === sectionIndex - ? { - ...section, - timeRange: { - ...section.timeRange, - [boundary]: value, - }, - } - : currentSection, - ), - }; - const nextLoop = - boundary === "start" - ? { ...selectedLoop, startSeconds: value } - : { ...selectedLoop, endSeconds: value }; - setBoundaryDraft((current) => ({ - ...current, - [boundary]: String(value), - })); - setSelectedLoopKey(loopSelectionKey(nextLoop)); - onSongUpdate(nextSong); - }, - [ - hasPlayableAudio, - mediaDurationSeconds, - onSongUpdate, - selectedLoop, - song, - ], - ); - const canSeek = - transport.loop !== null && - hasPlayableAudio && - loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && - (transport.phase === "looping" || - (transport.phase === "paused" && transport.countInRemainingBeats === 0)); - const handleSeek = useCallback( - (event: ChangeEvent) => { - if (!canSeek || !transport.loop || !audioSourceUrl) { - return; - } - const nextTransport = reduceRehearsalTransport(transport, { - type: "seek", - playheadSeconds: Number(event.currentTarget.value), - }); - try { - const audio = audioRef.current; - if (!audio) { - return; - } - audio.currentTime = nextTransport.playheadSeconds; - setPlaybackError(false); - setTransport(nextTransport); - } catch { - handlePlaybackError(); - } + + // Native stem authority is revocable. Drop the failed stem before awaiting IPC. + discoverCurrentPlaybackSources(current, current.fullMixAuthority); }, - [audioSourceUrl, canSeek, handlePlaybackError, transport], + [audioSourcePath, discoverCurrentPlaybackSources], ); - const startOrResume = useCallback(() => { - if (!canStart) { - return; - } - setPlaybackError(false); - if (transport.loop) { - startAudio( - transport.loop, - transport.phase === "paused" && transport.countInRemainingBeats === 0, - ); - } - setTransport((current) => - reduceRehearsalTransport(current, { type: "start" }), - ); - }, [canStart, startAudio, transport]); - const pauseTransport = useCallback(() => { - if (!canPause) { - return; - } - playbackIntentRef.current = "inactive"; - setTransport((current) => - reduceRehearsalTransport(current, { type: "pause" }), - ); - }, [canPause]); - const stopTransport = useCallback(() => { - if (!canStop) { - return; - } - playbackIntentRef.current = "inactive"; - setTransport((current) => - reduceRehearsalTransport(current, { type: "stop" }), - ); - }, [canStop]); - useEffect(() => { - /** Keep transport shortcuts out of editable controls. */ - const handleTransportShortcut = (event: KeyboardEvent) => { - const target = event.target; - const targetIsButtonOrLink = - target instanceof Element && target.closest("button, a") !== null; - const targetIsScrollableRegion = - target instanceof Element && - target.closest('[role="region"][tabindex="0"]') !== null; - const targetIsEditable = - target instanceof HTMLElement && - (target.isContentEditable || - target.closest("input, select, textarea") !== null); - if ( - event.defaultPrevented || - event.repeat || - targetIsEditable - ) { - return; - } - if ( - event.key === " " && - !targetIsButtonOrLink && - !targetIsScrollableRegion && - !event.altKey && - !event.ctrlKey && - !event.metaKey && - !event.shiftKey && - (canPause || canStart) - ) { - event.preventDefault(); - if (canPause) { - pauseTransport(); - } else { - startOrResume(); - } - } else if ( - event.key === "Escape" && - !event.altKey && - !event.ctrlKey && - !event.metaKey && - !event.shiftKey && - canStop - ) { - event.preventDefault(); - stopTransport(); - } - }; - window.addEventListener("keydown", handleTransportShortcut); - return () => window.removeEventListener("keydown", handleTransportShortcut); - }, [canPause, canStart, canStop, pauseTransport, startOrResume, stopTransport]); + + const sessionMatchesMountedProject = + hasLocalAudio && sourceSession.fullMixAuthority === audioSourcePath; + const visibleOptions = sessionMatchesMountedProject + ? sourceSession.options + : []; + const selectedAuthority = sessionMatchesMountedProject + ? sourceSession.selectedAuthority ?? audioSourcePath + : hasLocalAudio + ? audioSourcePath + : null; + const sourceDiscoveryPending = + sessionMatchesMountedProject && sourceSession.pendingRequest !== null; + const sourceDiscoveryStatus = + sessionMatchesMountedProject && + !sourceDiscoveryPending && + sourceDiscoveryFeedback?.fullMixAuthority === audioSourcePath + ? sourceDiscoveryFeedback.status + : null; + const hasStemChoices = visibleOptions.length > 1; + // A new full-mix authority is a new project/generation boundary; it must not + // inherit transport phase or a renderer-local source-switch receipt. + const mountedProjectKey = hasLocalAudio + ? audioSourcePath ?? "local-audio-without-authority" + : "no-local-audio"; return ( -
-

- {t("workspaceLoopTitle")} -

-

- {nextAction} -

- {activeRoleName && playableLoops.length > 0 ? ( +
+ {sourceDiscoveryPending ? (

- {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), { - roleName: activeRoleName, - })} + {playbackSourceCopy("loading")}

) : null} - {playableLoops.length > 0 ? ( -
- {playableLoops.map((loop, index) => { - const selectionKey = loopSelectionKey(loop); - const selected = - selectedLoop !== null && - selectionKey === loopSelectionKey(selectedLoop); - return ( - - ); - })} -
- ) : null} - {playableLoops.length > 1 ? ( + {sourceDiscoveryStatus === "empty" ? (

- {t("workspaceLoopSectionKeyboardHint")} + {playbackSourceCopy("empty")}

) : null} - {selectedLoop && onSongUpdate ? ( -
-
-

- {t("workspaceLoopBoundaryTitle")} -

- - {t("workspaceLoopBoundaryCorrectionBadge")} - -
-

- {boundaryError - ? t("workspaceLoopBoundaryError") - : t("workspaceLoopBoundaryHint")} + {sourceDiscoveryStatus === "error" ? ( +

+

+ {playbackSourceCopy("error")}

-
- - -
+
) : null} -
- ))} - - -

- {t("workspaceLoopPlaybackRateHint")} -

-
- + ) : null} -
+ ); -} \ No newline at end of file +} diff --git a/apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx b/apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx new file mode 100644 index 000000000..e223c1413 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayerCore.tsx @@ -0,0 +1,1380 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type KeyboardEvent as ReactKeyboardEvent, + type ReactElement, +} from "react"; +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalSong, +} from "@bandscope/shared-types"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + type TranslationKey, +} from "../../i18n"; +import { + createRehearsalCountInClickEngine, + type RehearsalCountInClickEngine, +} from "./rehearsalCountInClick"; +import { + beatDurationMs, + createIdleTransportState, + fillRehearsalCopy, + formatRehearsalClock, + nextActionTemplateKey, + nextActionValues, + isRehearsalPlaybackRate, + rehearsalPlaybackRates, + reduceRehearsalTransport, + resolveLoopWindows, + type RehearsalLoopWindow, + type RehearsalTransportState, +} from "./rehearsalTransport"; +import { + abortPlaybackSourceSwitch, + admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, + completePlaybackSourceSwitch, + createPlaybackSourceSwitchSession, + type PlaybackSourceSwitchPlan, + type PlaybackSourceSwitchSession, +} from "./playbackSourceSwitch"; + +interface RehearsalPlayerProps { + song: RehearsalSong; + onSongUpdate?: (song: RehearsalSong) => void; + onSelectedSectionIndexChange?: (sectionIndex: number | null) => void; + sectionSelectionRequest?: { + sectionIndex: number; + requestId: number; + } | null; + hasLocalAudio?: boolean; + audioSourcePath?: string | null; + activeRole?: string | null; + activeRoleName?: string | null; + startNonce?: number; +} + +const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://"; +const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/; +const PLAYBACK_SOURCE_SUFFIX = /^(?:\/stem\/(?:vocals|bass|drums|other))?$/; + +/** Convert an opaque current-project authority into BandScope's native media URL. */ +function resolveAudioSourceUrl( + sourcePath: string | null | undefined, +): string | null { + if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) { + return null; + } + const authorityToken = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); + const separatorIndex = authorityToken.indexOf("/"); + const projectId = + separatorIndex === -1 + ? authorityToken + : authorityToken.slice(0, separatorIndex); + const sourceSuffix = + separatorIndex === -1 ? "" : authorityToken.slice(separatorIndex); + if ( + !PLAYBACK_PROJECT_ID.test(projectId) || + !PLAYBACK_SOURCE_SUFFIX.test(sourceSuffix) + ) { + return null; + } + try { + return convertFileSrc(`${projectId}${sourceSuffix}`, "bandscope-playback"); + } catch { + return null; + } +} + +/** Return whether a source authority can be converted into a playable native URL. */ +export function isPlayableAudioSource( + sourcePath: string | null | undefined, +): boolean { + return resolveAudioSourceUrl(sourcePath) !== null; +} + +/** Return the displayed map-clock progress for the current loop. */ +function loopProgressPercent(state: RehearsalTransportState): number { + if (!state.loop) { + return 0; + } + const duration = state.loop.endSeconds - state.loop.startSeconds; + if (!(duration > 0)) { + return 0; + } + return Math.min( + 100, + Math.max( + 0, + ((state.playheadSeconds - state.loop.startSeconds) / duration) * 100, + ), + ); +} + +/** Return whether two loop windows describe the same transport timing authority. */ +function hasSameLoopTiming( + current: RehearsalLoopWindow, + next: RehearsalLoopWindow, +): boolean { + return ( + current.selectionKey === next.selectionKey && + current.sectionId === next.sectionId && + current.startSeconds === next.startSeconds && + current.endSeconds === next.endSeconds && + current.tempoBpm === next.tempoBpm && + current.countInBeats === next.countInBeats + ); +} + +/** Return a stable selection key when analysis emits duplicate section IDs. */ +function loopSelectionKey(loop: RehearsalLoopWindow): string { + return loop.selectionKey; +} + +/** Return whether a selected loop is fully covered by admitted local media. */ +function loopFitsAdmittedMedia( + loop: RehearsalLoopWindow, + mediaDurationSeconds: number | null, +): boolean { + return ( + mediaDurationSeconds !== null && + Number.isFinite(mediaDurationSeconds) && + mediaDurationSeconds > 0 && + loop.startSeconds < mediaDurationSeconds && + loop.endSeconds <= mediaDurationSeconds + ); +} + +/** Render tonight's first section loop with a count-in and a named next action. */ +export function RehearsalPlayer({ + song, + onSongUpdate, + onSelectedSectionIndexChange, + sectionSelectionRequest = null, + hasLocalAudio = false, + audioSourcePath = null, + activeRole = null, + activeRoleName = null, + startNonce = 0, +}: RehearsalPlayerProps): ReactElement { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const playableLoops = useMemo( + () => resolveLoopWindows(song, activeRole), + [activeRole, song], + ); + const [selectedLoopKey, setSelectedLoopKey] = useState(null); + const lastHandledSectionSelectionRequestId = useRef(0); + const [boundaryError, setBoundaryError] = useState(false); + const selectedLoop = + playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? + playableLoops[0] ?? + null; + useEffect(() => { + onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null); + }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]); + useEffect(() => { + if (!sectionSelectionRequest) { + return; + } + const { requestId, sectionIndex } = sectionSelectionRequest; + if ( + !Number.isSafeInteger(requestId) || + requestId <= lastHandledSectionSelectionRequestId.current + ) { + return; + } + lastHandledSectionSelectionRequestId.current = requestId; + if ( + !Number.isSafeInteger(sectionIndex) || + sectionIndex < 0 || + sectionIndex >= song.sections.length + ) { + return; + } + const requestedLoop = playableLoops.find( + (loop) => loop.sourceIndex === sectionIndex, + ); + if (!requestedLoop) { + return; + } + setSelectedLoopKey(loopSelectionKey(requestedLoop)); + }, [playableLoops, sectionSelectionRequest, song.sections.length]); + const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; + const [boundaryDraft, setBoundaryDraft] = useState(() => ({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + })); + useEffect(() => { + setBoundaryError(false); + setBoundaryDraft({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + }); + }, [selectedBoundaryKey, selectedLoop?.endSeconds, selectedLoop?.startSeconds]); + const handleSectionKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + const focusedIndex = Number(event.currentTarget.dataset.loopIndex); + const selectedIndex = selectedLoop + ? playableLoops.indexOf(selectedLoop) + : -1; + const currentIndex = + Number.isSafeInteger(focusedIndex) && + focusedIndex >= 0 && + focusedIndex < playableLoops.length + ? focusedIndex + : selectedIndex; + const nextIndex = + currentIndex + (event.key === "ArrowRight" ? 1 : -1); + if ( + currentIndex < 0 || + nextIndex < 0 || + nextIndex >= playableLoops.length + ) { + return; + } + event.preventDefault(); + const nextLoop = playableLoops[nextIndex]; + setSelectedLoopKey(loopSelectionKey(nextLoop)); + document + .getElementById( + `rehearsal-loop-section-${loopSelectionKey(nextLoop)}-${nextIndex}`, + ) + ?.focus(); + }, + [playableLoops, selectedLoop], + ); + const [transport, setTransport] = useState(() => + reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop: playableLoops[0] ?? null, + }), + ); + const transportRef = useRef(transport); + useEffect(() => { + transportRef.current = transport; + }, [transport]); + const countInClickEngine = useMemo( + () => createRehearsalCountInClickEngine(), + [], + ); + const lastHandledStartNonce = useRef(0); + const restartAudioOnLoopRef = useRef(false); + const lastCountInClickKeyRef = useRef(null); + const countInBeatRef = useRef<{ + durationMs: number; + startedAt: number; + remainingBeats: number; + progress: number; + } | null>(null); + const audioRef = useRef(null); + const playbackIntentRef = useRef<"active" | "inactive">("inactive"); + const playRequestSequenceRef = useRef(0); + const playbackSourceSwitchSessionRef = useRef( + createPlaybackSourceSwitchSession(), + ); + const loadedPlaybackAuthorityRef = useRef(null); + const sourceSwitchPendingRef = useRef(false); + const [sourceSwitchPending, setSourceSwitchPending] = useState(false); + const audioSourceUrl = useMemo( + () => resolveAudioSourceUrl(audioSourcePath), + [audioSourcePath], + ); + const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null; + const hasNativeAudioConversionError = Boolean( + hasLocalAudio && + audioSourcePath && + !audioSourcePath.startsWith("browser://") && + audioSourceUrl === null, + ); + const [playbackError, setPlaybackError] = useState(false); + const [mediaDurationSeconds, setMediaDurationSeconds] = useState( + null, + ); + + useEffect( + () => () => { + void countInClickEngine.dispose(); + }, + [countInClickEngine], + ); + + const handlePlaybackError = useCallback(() => { + playbackIntentRef.current = "inactive"; + setPlaybackError(true); + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, []); + + const handlePlayRejection = useCallback( + (error: unknown, requestSequence: number) => { + if (requestSequence !== playRequestSequenceRef.current) { + return; + } + const expectedInterruption = + playbackIntentRef.current === "inactive" && + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError"; + if (!expectedInterruption) { + handlePlaybackError(); + } + }, + [handlePlaybackError], + ); + + const startAudio = useCallback( + (loop: RehearsalLoopWindow, resume: boolean) => { + const audio = audioRef.current; + if (!audio || !audioSourceUrl) { + handlePlaybackError(); + return; + } + try { + restartAudioOnLoopRef.current = !resume; + if (!resume) { + audio.currentTime = loop.startSeconds; + audio.volume = 0; + } else { + audio.volume = 1; + } + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, handlePlaybackError, handlePlayRejection], + ); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return undefined; + } + + const targetAuthority = + hasLocalAudio && audioSourceUrl !== null ? audioSourcePath : null; + const sourceAuthority = loadedPlaybackAuthorityRef.current; + let plan: PlaybackSourceSwitchPlan | null = null; + let admissionSettled = false; + + if ( + sourceAuthority !== null && + targetAuthority !== null && + sourceAuthority !== targetAuthority + ) { + const started = beginPlaybackSourceSwitch( + playbackSourceSwitchSessionRef.current, + transportRef.current, + audio.currentTime, + sourceAuthority, + targetAuthority, + ); + playbackSourceSwitchSessionRef.current = started.state; + plan = started.plan; + } else if (playbackSourceSwitchSessionRef.current.activePlan !== null) { + playbackSourceSwitchSessionRef.current = abortPlaybackSourceSwitch( + playbackSourceSwitchSessionRef.current, + playbackSourceSwitchSessionRef.current.activePlan, + ); + } + + const pending = plan !== null; + sourceSwitchPendingRef.current = pending; + setSourceSwitchPending(pending); + if (sourceAuthority !== targetAuthority && plan === null) { + loadedPlaybackAuthorityRef.current = null; + } + + const retireFailedPlan = (reportPlaybackError: boolean): void => { + if (admissionSettled) { + return; + } + admissionSettled = true; + if (plan !== null) { + playbackSourceSwitchSessionRef.current = abortPlaybackSourceSwitch( + playbackSourceSwitchSessionRef.current, + plan, + ); + } + loadedPlaybackAuthorityRef.current = null; + sourceSwitchPendingRef.current = false; + setSourceSwitchPending(false); + setMediaDurationSeconds(null); + if (reportPlaybackError) { + handlePlaybackError(); + } + }; + + /** Admit metadata only for the exact source mutation owned by this effect. */ + const admitLoadedSource = (): void => { + if (admissionSettled) { + return; + } + const duration = audio.duration; + if ( + targetAuthority === null || + !Number.isFinite(duration) || + duration <= 0 + ) { + retireFailedPlan(true); + return; + } + + if (plan === null) { + admissionSettled = true; + loadedPlaybackAuthorityRef.current = targetAuthority; + setMediaDurationSeconds(duration); + setPlaybackError(false); + return; + } + + const admittedPlan = admitPlaybackSourceSwitchTarget( + playbackSourceSwitchSessionRef.current, + plan, + duration, + targetAuthority, + ); + if (admittedPlan === null) { + retireFailedPlan(true); + return; + } + + try { + audio.currentTime = admittedPlan.seekSeconds; + audio.playbackRate = admittedPlan.playbackRate; + if ("preservesPitch" in audio) { + audio.preservesPitch = true; + } + if (admittedPlan.sourcePhase === "looping") { + setTransport((current) => + reduceRehearsalTransport(current, { + type: "sync", + playheadSeconds: admittedPlan.seekSeconds, + }), + ); + } + loadedPlaybackAuthorityRef.current = admittedPlan.targetAuthority; + setMediaDurationSeconds(duration); + setPlaybackError(false); + playbackSourceSwitchSessionRef.current = completePlaybackSourceSwitch( + playbackSourceSwitchSessionRef.current, + admittedPlan, + ); + admissionSettled = true; + sourceSwitchPendingRef.current = false; + setSourceSwitchPending(false); + } catch { + retireFailedPlan(true); + } + }; + + const failLoadedSource = (): void => { + if (admissionSettled) { + handlePlaybackError(); + return; + } + retireFailedPlan(true); + }; + audio.addEventListener("loadedmetadata", admitLoadedSource); + audio.addEventListener("error", failLoadedSource); + + // A source replacement retires unresolved play() outcomes from the previous media resource. + playRequestSequenceRef.current += 1; + playbackIntentRef.current = "inactive"; + setMediaDurationSeconds(null); + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + if (audioSourceUrl) { + audio.src = audioSourceUrl; + audio.load(); + } else { + loadedPlaybackAuthorityRef.current = null; + sourceSwitchPendingRef.current = false; + setSourceSwitchPending(false); + audio.removeAttribute("src"); + } + setPlaybackError(hasNativeAudioConversionError); + + return () => { + audio.removeEventListener("loadedmetadata", admitLoadedSource); + audio.removeEventListener("error", failLoadedSource); + if (plan !== null) { + playbackSourceSwitchSessionRef.current = abortPlaybackSourceSwitch( + playbackSourceSwitchSessionRef.current, + plan, + ); + } + sourceSwitchPendingRef.current = false; + playbackIntentRef.current = "inactive"; + if (!audio.paused) { + audio.pause(); + } + }; + }, [ + audioSourcePath, + audioSourceUrl, + handlePlaybackError, + hasLocalAudio, + hasNativeAudioConversionError, + ]); + + useEffect(() => { + setTransport((current) => { + if ( + current.loop && + selectedLoop && + hasSameLoopTiming(current.loop, selectedLoop) + ) { + if ( + current.loop.sectionLabel === selectedLoop.sectionLabel && + current.loop.tempoAssumed === selectedLoop.tempoAssumed && + current.loop.sourceIndex === selectedLoop.sourceIndex + ) { + return current; + } + return { ...current, loop: selectedLoop }; + } + return reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + }); + }, [selectedLoop]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return; + } + try { + audio.playbackRate = transport.playbackRate; + if ("preservesPitch" in audio) { + audio.preservesPitch = true; + } + } catch { + handlePlaybackError(); + } + }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]); + + useEffect(() => { + if (startNonce <= lastHandledStartNonce.current) { + return; + } + if (!hasPlayableAudio || !selectedLoop || mediaDurationSeconds === null) { + return; + } + if (!loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)) { + lastHandledStartNonce.current = startNonce; + return; + } + lastHandledStartNonce.current = startNonce; + setPlaybackError(false); + startAudio(selectedLoop, false); + setTransport((current) => { + const armed = reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + return reduceRehearsalTransport(armed, { type: "start" }); + }); + }, [ + startAudio, + startNonce, + hasPlayableAudio, + mediaDurationSeconds, + selectedLoop, + ]); + + useEffect(() => { + if (sourceSwitchPending) { + return; + } + const transportLoopCovered = + transport.loop === null || + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds); + if (hasPlayableAudio && transportLoopCovered) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, [ + hasPlayableAudio, + mediaDurationSeconds, + sourceSwitchPending, + transport.loop, + ]); + + useEffect(() => { + if (transport.phase !== "counting-in" || !transport.loop) { + countInBeatRef.current = null; + lastCountInClickKeyRef.current = null; + countInClickEngine.stop(); + return undefined; + } + const durationMs = + beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate; + const now = performance.now(); + const previous = countInBeatRef.current; + const sameBeat = + previous?.remainingBeats === transport.countInRemainingBeats; + const elapsedProgress = sameBeat + ? Math.max(0, now - previous.startedAt) / previous.durationMs + : 0; + const progress = sameBeat + ? Math.min(1, previous.progress + elapsedProgress) + : 0; + const currentClickKey = `${transport.loop.selectionKey}:${transport.countInRemainingBeats}`; + if ( + countInClickEngine.available && + lastCountInClickKeyRef.current !== currentClickKey + ) { + lastCountInClickKeyRef.current = currentClickKey; + void countInClickEngine + .click(transport.countInRemainingBeats === transport.loop.countInBeats) + .catch(() => { + // Count-in click failure must not gain authority over admitted song playback. + }); + } + countInBeatRef.current = { + durationMs, + startedAt: now, + remainingBeats: transport.countInRemainingBeats, + progress, + }; + let timer: number | undefined; + /** Schedule the next count-in beat without coupling it to React commits. */ + const scheduleBeat = (delayMs: number) => { + timer = window.setTimeout(() => { + const current = countInBeatRef.current; + if (!current || current.remainingBeats <= 0) { + return; + } + current.remainingBeats -= 1; + current.progress = 0; + setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); + if (current.remainingBeats > 0) { + const nextClickKey = `${transport.loop?.selectionKey ?? ""}:${current.remainingBeats}`; + if ( + countInClickEngine.available && + lastCountInClickKeyRef.current !== nextClickKey + ) { + lastCountInClickKeyRef.current = nextClickKey; + void countInClickEngine.click(false).catch(() => { + // The transport remains authoritative when Web Audio is unavailable. + }); + } + current.startedAt = performance.now(); + scheduleBeat(current.durationMs); + } + }, delayMs); + }; + scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [ + countInClickEngine, + transport.loop, + transport.phase, + transport.playbackRate, + ]); + + useEffect(() => { + if ( + !audioSourceUrl || + !transport.loop || + sourceSwitchPendingRef.current + ) { + return undefined; + } + const audio = audioRef.current; + if (!audio) { + return undefined; + } + if (transport.phase === "looping") { + try { + if (restartAudioOnLoopRef.current) { + audio.currentTime = transport.loop.startSeconds; + restartAudioOnLoopRef.current = false; + } + audio.volume = 1; + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + } + } else if ( + transport.phase === "armed" || + transport.phase === "paused" || + transport.phase === "idle" + ) { + playbackIntentRef.current = "inactive"; + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + } + return undefined; + }, [ + audioSourceUrl, + handlePlaybackError, + handlePlayRejection, + sourceSwitchPending, + transport.phase, + transport.loop, + ]); + + useEffect(() => { + if ( + !audioSourceUrl || + sourceSwitchPendingRef.current || + transport.phase !== "looping" || + !transport.loop + ) { + return undefined; + } + const audio = audioRef.current; + if (!audio) { + return undefined; + } + const loop = transport.loop; + const playbackRate = transport.playbackRate; + let boundaryTimer: number | undefined; + /** Cancel the pending media-clock boundary check. */ + const clearBoundaryTimer = () => { + if (boundaryTimer !== undefined) { + window.clearTimeout(boundaryTimer); + boundaryTimer = undefined; + } + }; + /** Restart media at the exact selected section boundary. */ + const restartLoop = () => { + try { + audio.currentTime = loop.startSeconds; + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + return; + } + scheduleLoopBoundary(); + }; + /** Schedule a media-clock boundary check and reschedule if timers fire early. */ + const scheduleLoopBoundary = () => { + clearBoundaryTimer(); + const remainingSeconds = loop.endSeconds - audio.currentTime; + if (!Number.isFinite(remainingSeconds)) { + return; + } + if (remainingSeconds <= 0) { + restartLoop(); + return; + } + boundaryTimer = window.setTimeout(() => { + boundaryTimer = undefined; + if (audio.currentTime >= loop.endSeconds) { + restartLoop(); + } else { + scheduleLoopBoundary(); + } + }, + Math.min( + (remainingSeconds / playbackRate) * 1000, + 2_147_483_647, + ), + ); + }; + /** Keep the map playhead aligned with the scoped audio element. */ + const syncPlayhead = () => { + if (audio.currentTime >= loop.endSeconds) { + restartLoop(); + } else { + scheduleLoopBoundary(); + } + setTransport((current) => + reduceRehearsalTransport(current, { + type: "sync", + playheadSeconds: audio.currentTime, + }), + ); + }; + /** Stop the transport when the media element reports a real playback error. */ + const failPlayback = () => handlePlaybackError(); + audio.addEventListener("timeupdate", syncPlayhead); + audio.addEventListener("error", failPlayback); + audio.addEventListener("ended", restartLoop); + scheduleLoopBoundary(); + return () => { + clearBoundaryTimer(); + audio.removeEventListener("timeupdate", syncPlayhead); + audio.removeEventListener("error", failPlayback); + audio.removeEventListener("ended", restartLoop); + }; + }, [ + audioSourceUrl, + handlePlaybackError, + handlePlayRejection, + sourceSwitchPending, + transport.phase, + transport.loop, + transport.playbackRate, + ]); + + const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); + const nextAction = + activeRoleName && playableLoops.length === 0 + ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { + roleName: activeRoleName, + }) + : fillRehearsalCopy( + t(actionKey as TranslationKey), + nextActionValues(transport), + ); + const sectionPickerLabel = activeRoleName + ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { + roleName: activeRoleName, + }) + : t("workspaceLoopSectionPickerLabel"); + const canStart = + !sourceSwitchPending && + transport.loop !== null && + hasPlayableAudio && + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && + (transport.phase === "armed" || transport.phase === "paused"); + const canPause = + !sourceSwitchPending && + (transport.phase === "counting-in" || transport.phase === "looping"); + const canStop = + !sourceSwitchPending && transport.phase !== "idle" && transport.loop !== null; + const startLabel = + transport.phase === "paused" + ? t("workspaceLoopResume") + : t("workspaceLoopStart"); + const handleBoundaryBlur = useCallback( + (boundary: "start" | "end", event: FocusEvent) => { + if (!selectedLoop || !onSongUpdate) { + return; + } + const rawValue = event.currentTarget.value.trim(); + const value = Number(rawValue); + const withinLoadedMedia = + !hasPlayableAudio || + (mediaDurationSeconds !== null && + (boundary === "start" + ? value < mediaDurationSeconds + : value <= mediaDurationSeconds)); + const valid = + rawValue !== "" && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_SECTION_TIME_SECONDS && + withinLoadedMedia && + (boundary === "start" + ? value < selectedLoop.endSeconds + : value > selectedLoop.startSeconds); + if (!valid) { + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + setBoundaryError(true); + return; + } + + setBoundaryError(false); + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + if (value === currentValue) { + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + return; + } + + const sectionIndex = selectedLoop.sourceIndex; + const section = song.sections[sectionIndex]; + if ( + !section || + section.id !== selectedLoop.sectionId || + section.timeRange.start !== selectedLoop.startSeconds || + section.timeRange.end !== selectedLoop.endSeconds + ) { + setBoundaryDraft({ + end: String(selectedLoop.endSeconds), + start: String(selectedLoop.startSeconds), + }); + setBoundaryError(true); + return; + } + const nextSong = { + ...song, + sections: song.sections.map((currentSection, index) => + index === sectionIndex + ? { + ...section, + timeRange: { + ...section.timeRange, + [boundary]: value, + }, + } + : currentSection, + ), + }; + const nextLoop = + boundary === "start" + ? { ...selectedLoop, startSeconds: value } + : { ...selectedLoop, endSeconds: value }; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(value), + })); + setSelectedLoopKey(loopSelectionKey(nextLoop)); + onSongUpdate(nextSong); + }, + [ + hasPlayableAudio, + mediaDurationSeconds, + onSongUpdate, + selectedLoop, + song, + ], + ); + const canSeek = + !sourceSwitchPending && + transport.loop !== null && + hasPlayableAudio && + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && + (transport.phase === "looping" || + (transport.phase === "paused" && transport.countInRemainingBeats === 0)); + const handleSeek = useCallback( + (event: ChangeEvent) => { + if (!canSeek || !transport.loop || !audioSourceUrl) { + return; + } + const nextTransport = reduceRehearsalTransport(transport, { + type: "seek", + playheadSeconds: Number(event.currentTarget.value), + }); + try { + const audio = audioRef.current; + if (!audio) { + return; + } + audio.currentTime = nextTransport.playheadSeconds; + setPlaybackError(false); + setTransport(nextTransport); + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, canSeek, handlePlaybackError, transport], + ); + const startOrResume = useCallback(() => { + if (!canStart) { + return; + } + setPlaybackError(false); + if (transport.loop) { + startAudio( + transport.loop, + transport.phase === "paused" && transport.countInRemainingBeats === 0, + ); + } + setTransport((current) => + reduceRehearsalTransport(current, { type: "start" }), + ); + }, [canStart, startAudio, transport]); + const pauseTransport = useCallback(() => { + if (!canPause) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => + reduceRehearsalTransport(current, { type: "pause" }), + ); + }, [canPause]); + const stopTransport = useCallback(() => { + if (!canStop) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => + reduceRehearsalTransport(current, { type: "stop" }), + ); + }, [canStop]); + useEffect(() => { + /** Keep transport shortcuts out of editable controls. */ + const handleTransportShortcut = (event: KeyboardEvent) => { + const target = event.target; + const targetIsButtonOrLink = + target instanceof Element && target.closest("button, a") !== null; + const targetIsScrollableRegion = + target instanceof Element && + target.closest('[role="region"][tabindex="0"]') !== null; + const targetIsEditable = + target instanceof HTMLElement && + (target.isContentEditable || + target.closest("input, select, textarea") !== null); + if ( + event.defaultPrevented || + event.repeat || + targetIsEditable + ) { + return; + } + if ( + event.key === " " && + !targetIsButtonOrLink && + !targetIsScrollableRegion && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + (canPause || canStart) + ) { + event.preventDefault(); + if (canPause) { + pauseTransport(); + } else { + startOrResume(); + } + } else if ( + event.key === "Escape" && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + canStop + ) { + event.preventDefault(); + stopTransport(); + } + }; + window.addEventListener("keydown", handleTransportShortcut); + return () => window.removeEventListener("keydown", handleTransportShortcut); + }, [canPause, canStart, canStop, pauseTransport, startOrResume, stopTransport]); + + return ( +
+

+ {t("workspaceLoopTitle")} +

+

+ {nextAction} +

+ {activeRoleName && playableLoops.length > 0 ? ( +

+ {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), { + roleName: activeRoleName, + })} +

+ ) : null} + {playableLoops.length > 0 ? ( +
+ {playableLoops.map((loop, index) => { + const selectionKey = loopSelectionKey(loop); + const selected = + selectedLoop !== null && + selectionKey === loopSelectionKey(selectedLoop); + return ( + + ); + })} +
+ ) : null} + {playableLoops.length > 1 ? ( +

+ {t("workspaceLoopSectionKeyboardHint")} +

+ ) : null} + {selectedLoop && onSongUpdate ? ( +
+
+

+ {t("workspaceLoopBoundaryTitle")} +

+ + {t("workspaceLoopBoundaryCorrectionBadge")} + +
+

+ {boundaryError + ? t("workspaceLoopBoundaryError") + : t("workspaceLoopBoundaryHint")} +

+
+ + +
+
+ ) : null} +
+ +

+ {t("workspaceLoopPlaybackRateHint")} +

+
+
+ ); +} diff --git a/apps/desktop/src/features/workspace/playbackSourceCopy.ts b/apps/desktop/src/features/workspace/playbackSourceCopy.ts new file mode 100644 index 000000000..9ef9f7eba --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceCopy.ts @@ -0,0 +1,19 @@ +import type { Locale } from "../../i18n"; +import enPlaybackSource from "../../locales/en/playback-source.json"; +import koPlaybackSource from "../../locales/ko/playback-source.json"; + +type PlaybackSourceCopyKey = keyof typeof enPlaybackSource; + +const playbackSourceCopyByLocale: Readonly< + Record>> +> = { + en: enPlaybackSource, + ko: koPlaybackSource, +}; + +/** Return one localized playback-source screen string from the current resource set. */ +export function createPlaybackSourceCopy(locale: Locale) { + return function playbackSourceCopy(key: PlaybackSourceCopyKey): string { + return playbackSourceCopyByLocale[locale][key]; + }; +} diff --git a/apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts b/apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts new file mode 100644 index 000000000..5ad1eafb6 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceDiscovery.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { + discoverPlaybackSourceOptions, + discoverPlaybackSourceOutcome, +} from "./playbackSourceDiscovery"; + +const fullMix = "bandscope-project://project-100-1"; +const stems = [ + `${fullMix}/stem/vocals`, + `${fullMix}/stem/bass`, + `${fullMix}/stem/drums`, + `${fullMix}/stem/other`, +] as const; + +describe("playback source native discovery", () => { + it("invokes the native availability command with only the current opaque full-mix authority", async () => { + const invokeCommand = vi.fn().mockResolvedValue([ + stems[3], + fullMix, + stems[1], + stems[0], + stems[2], + ]); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toEqual([ + { kind: "full_mix", authority: fullMix }, + { kind: "vocals", authority: stems[0] }, + { kind: "bass", authority: stems[1] }, + { kind: "drums", authority: stems[2] }, + { kind: "other", authority: stems[3] }, + ]); + expect(invokeCommand).toHaveBeenCalledTimes(1); + expect(invokeCommand).toHaveBeenCalledWith( + "get_playback_source_availability", + { currentFullMixAuthority: fullMix }, + ); + }); + + it("keeps full mix usable when native authority has no registered stems", async () => { + const invokeCommand = vi.fn().mockResolvedValue([fullMix]); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toEqual([{ kind: "full_mix", authority: fullMix }]); + }); + + it("classifies a verified full-mix-only response as an empty stem state", async () => { + const invokeCommand = vi.fn().mockResolvedValue([fullMix]); + + await expect( + discoverPlaybackSourceOutcome(fullMix, invokeCommand), + ).resolves.toEqual({ + status: "empty", + options: [{ kind: "full_mix", authority: fullMix }], + }); + }); + + it.each([ + ["partial stem set", [fullMix, stems[0], stems[1]]], + ["stale project", ["bandscope-project://project-101-2"]], + ["native path", [fullMix, "/private/tmp/vocals.wav"]], + ["malformed payload", { fullMix }], + ])("fails closed on %s returned by IPC", async (_label, payload) => { + const invokeCommand = vi.fn().mockResolvedValue(payload); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toBeNull(); + }); + + it("fails closed without echoing native invocation failures", async () => { + const invokeCommand = vi + .fn() + .mockRejectedValue(new Error("/private/tmp/secret-source.wav")); + + await expect( + discoverPlaybackSourceOptions(fullMix, invokeCommand), + ).resolves.toBeNull(); + }); + + it("classifies native invocation failure without exposing the native error", async () => { + const invokeCommand = vi + .fn() + .mockRejectedValue(new Error("/private/tmp/secret-source.wav")); + + await expect( + discoverPlaybackSourceOutcome(fullMix, invokeCommand), + ).resolves.toEqual({ status: "error", options: null }); + }); + + it.each([null, undefined, "file:///private/tmp/source.wav", `${fullMix}/stem/vocals`])( + "does not invoke native discovery for a non-full-mix authority: %s", + async (candidate) => { + const invokeCommand = vi.fn().mockResolvedValue([fullMix]); + + await expect( + discoverPlaybackSourceOptions(candidate, invokeCommand), + ).resolves.toBeNull(); + expect(invokeCommand).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/desktop/src/features/workspace/playbackSourceDiscovery.ts b/apps/desktop/src/features/workspace/playbackSourceDiscovery.ts new file mode 100644 index 000000000..e3be9c2a0 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceDiscovery.ts @@ -0,0 +1,67 @@ +import { + derivePlaybackSourceOptions, + type PlaybackSourceOption, +} from "./playbackSourceSelection"; + +/** Minimal invoke boundary used to discover renderer-safe native playback sources. */ +export type PlaybackSourceInvoke = ( + command: string, + args?: Record, +) => Promise; + +/** Buyer-relevant result of one native source-availability lookup. */ +export type PlaybackSourceDiscoveryOutcome = + | { status: "ready"; options: PlaybackSourceOption[] } + | { status: "empty"; options: PlaybackSourceOption[] } + | { status: "error"; options: null }; + +/** + * Discover the currently registered playback sources and preserve the reason a + * selector is absent without exposing native error details to the renderer. + */ +export async function discoverPlaybackSourceOutcome( + currentFullMixAuthority: string | null | undefined, + invokeCommand: PlaybackSourceInvoke, +): Promise { + if ( + derivePlaybackSourceOptions(currentFullMixAuthority, [currentFullMixAuthority]) === + null + ) { + return { status: "error", options: null }; + } + + try { + const availableAuthorities = await invokeCommand( + "get_playback_source_availability", + { currentFullMixAuthority }, + ); + const options = derivePlaybackSourceOptions( + currentFullMixAuthority, + availableAuthorities, + ); + if (options === null) { + return { status: "error", options: null }; + } + return options.length > 1 + ? { status: "ready", options } + : { status: "empty", options }; + } catch { + return { status: "error", options: null }; + } +} + +/** + * Discover the currently registered playback sources without creating authority. + * + * The caller must already own the current opaque full-mix authority. Native IPC + * may only return opaque authorities; every response is revalidated by the + * renderer projector before it can become a buyer-visible source option. + */ +export async function discoverPlaybackSourceOptions( + currentFullMixAuthority: string | null | undefined, + invokeCommand: PlaybackSourceInvoke, +): Promise { + return ( + await discoverPlaybackSourceOutcome(currentFullMixAuthority, invokeCommand) + ).options; +} diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts new file mode 100644 index 000000000..b10500c9b --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + derivePlaybackSourceOptions, + playbackSourceProjectId, +} from "./playbackSourceSelection"; + +const fullMix = "bandscope-project://project-100-1"; +const stems = { + vocals: `${fullMix}/stem/vocals`, + bass: `${fullMix}/stem/bass`, + drums: `${fullMix}/stem/drums`, + other: `${fullMix}/stem/other`, +} as const; + +describe("playback source selection authority", () => { + it("projects one complete native authority set into canonical rehearsal order", () => { + expect( + derivePlaybackSourceOptions(fullMix, [ + stems.other, + stems.drums, + fullMix, + stems.vocals, + stems.bass, + ]), + ).toEqual([ + { kind: "full_mix", authority: fullMix }, + { kind: "vocals", authority: stems.vocals }, + { kind: "bass", authority: stems.bass }, + { kind: "drums", authority: stems.drums }, + { kind: "other", authority: stems.other }, + ]); + }); + + it("keeps full mix usable while no generated stems are registered", () => { + expect(derivePlaybackSourceOptions(fullMix, [fullMix])).toEqual([ + { kind: "full_mix", authority: fullMix }, + ]); + }); + + it("fails closed when native availability claims only part of the atomic four-stem set", () => { + expect( + derivePlaybackSourceOptions(fullMix, [fullMix, stems.vocals, stems.bass]), + ).toBeNull(); + }); + + it.each([ + ["duplicate authority", [fullMix, fullMix]], + [ + "mismatched project", + [fullMix, "bandscope-project://project-101-2/stem/vocals"], + ], + ["unknown stem", [fullMix, `${fullMix}/stem/guitar`]], + ["path-shaped suffix", [fullMix, `${fullMix}/stem/vocals/../private.wav`]], + ["non-string entry", [fullMix, 42]], + ["non-array payload", { fullMix }], + ])("rejects %s instead of inventing a buyer-visible source", (_label, payload) => { + expect(derivePlaybackSourceOptions(fullMix, payload)).toBeNull(); + }); + + it("rejects stale availability after the current project authority rotates", () => { + expect( + derivePlaybackSourceOptions("bandscope-project://project-101-2", [ + fullMix, + stems.vocals, + stems.bass, + stems.drums, + stems.other, + ]), + ).toBeNull(); + }); + + it("extracts project identity only from canonical opaque playback authorities", () => { + expect(playbackSourceProjectId(fullMix)).toBe("project-100-1"); + expect(playbackSourceProjectId(stems.other)).toBe("project-100-1"); + for (const invalid of [ + null, + 42, + "file:///private/source.wav", + "bandscope-project://project-100-1/stem/guitar", + `${stems.vocals}/../private.wav`, + ]) { + expect(playbackSourceProjectId(invalid)).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/features/workspace/playbackSourceSelection.ts b/apps/desktop/src/features/workspace/playbackSourceSelection.ts new file mode 100644 index 000000000..b91f65474 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSelection.ts @@ -0,0 +1,103 @@ +/** Renderer-visible source kinds backed by the current native playback authority. */ +export type PlaybackSourceKind = + | "full_mix" + | "vocals" + | "bass" + | "drums" + | "other"; + +/** One opaque, project-scoped source that the rehearsal player may select. */ +export interface PlaybackSourceOption { + kind: PlaybackSourceKind; + authority: string; +} + +const FULL_MIX_AUTHORITY = /^bandscope-project:\/\/(project-[0-9]+-[0-9]+)$/; +const SOURCE_AUTHORITY = + /^bandscope-project:\/\/(project-[0-9]+-[0-9]+)(?:\/stem\/(vocals|bass|drums|other))?$/; +const STEM_ORDER = ["vocals", "bass", "drums", "other"] as const; + +/** Return the owning project id only for one canonical renderer playback authority. */ +export function playbackSourceProjectId(authority: unknown): string | null { + if (typeof authority !== "string") { + return null; + } + return authority.match(SOURCE_AUTHORITY)?.[1] ?? null; +} + +/** + * Project native availability into renderer options without creating authority. + * A generated set is all-or-nothing because native admission binds four stems atomically. + */ +export function derivePlaybackSourceOptions( + currentFullMixAuthority: string | null | undefined, + availableAuthorities: unknown, +): PlaybackSourceOption[] | null { + if (typeof currentFullMixAuthority !== "string") { + return null; + } + const currentMatch = currentFullMixAuthority.match(FULL_MIX_AUTHORITY); + if (!currentMatch || !Array.isArray(availableAuthorities)) { + return null; + } + + const projectId = currentMatch[1]; + const seen = new Set(); + let hasFullMix = false; + const stems = new Map<(typeof STEM_ORDER)[number], string>(); + + for (const candidate of availableAuthorities) { + if (typeof candidate !== "string" || seen.has(candidate)) { + return null; + } + seen.add(candidate); + + const match = candidate.match(SOURCE_AUTHORITY); + if (!match || match[1] !== projectId) { + return null; + } + + const stemKind = match[2] as (typeof STEM_ORDER)[number] | undefined; + if (stemKind === undefined) { + if (candidate !== currentFullMixAuthority || hasFullMix) { + return null; + } + hasFullMix = true; + continue; + } + + if (stems.has(stemKind)) { + return null; + } + stems.set(stemKind, candidate); + } + + if (!hasFullMix) { + return null; + } + + if (stems.size === 0) { + return [{ kind: "full_mix", authority: currentFullMixAuthority }]; + } + + if ( + stems.size !== STEM_ORDER.length || + STEM_ORDER.some((stemKind) => !stems.has(stemKind)) + ) { + return null; + } + + const stemOptions: PlaybackSourceOption[] = []; + for (const stemKind of STEM_ORDER) { + const authority = stems.get(stemKind); + if (authority === undefined) { + return null; + } + stemOptions.push({ kind: stemKind, authority }); + } + + return [ + { kind: "full_mix", authority: currentFullMixAuthority }, + ...stemOptions, + ]; +} diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts new file mode 100644 index 000000000..be3bfc677 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSession.immutability.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + beginPlaybackSourceDiscovery, + completePlaybackSourceDiscovery, + createPlaybackSourceSession, + selectPlaybackSource, +} from "./playbackSourceSession"; + +const projectA = "bandscope-project://project-100-1"; +const projectAOptions = [ + { kind: "full_mix" as const, authority: projectA }, + { kind: "vocals" as const, authority: `${projectA}/stem/vocals` }, + { kind: "bass" as const, authority: `${projectA}/stem/bass` }, + { kind: "drums" as const, authority: `${projectA}/stem/drums` }, + { kind: "other" as const, authority: `${projectA}/stem/other` }, +]; + +describe("playback source session receipt integrity", () => { + it("keeps authority-bearing session snapshots immutable across discovery and selection", () => { + const initial = createPlaybackSourceSession(projectA); + expect(Object.isFrozen(initial)).toBe(true); + expect(Object.isFrozen(initial.options)).toBe(true); + expect(Object.isFrozen(initial.options[0])).toBe(true); + + const refresh = beginPlaybackSourceDiscovery(initial, projectA); + expect(Object.isFrozen(refresh.state)).toBe(true); + expect(Object.isFrozen(refresh.state.options)).toBe(true); + expect(Object.isFrozen(refresh.request)).toBe(true); + + const completed = completePlaybackSourceDiscovery( + refresh.state, + refresh.request, + projectAOptions, + ); + expect(Object.isFrozen(completed)).toBe(true); + expect(Object.isFrozen(completed.options)).toBe(true); + expect(completed.options.every(Object.isFrozen)).toBe(true); + expect(Reflect.set(completed.options[1], "authority", `${projectA}/stem/guitar`)).toBe( + false, + ); + expect(completed.options[1]?.authority).toBe(`${projectA}/stem/vocals`); + + const selected = selectPlaybackSource(completed, `${projectA}/stem/drums`); + expect(Object.isFrozen(selected)).toBe(true); + expect(Object.isFrozen(selected.options)).toBe(true); + expect(selected.selectedAuthority).toBe(`${projectA}/stem/drums`); + expect(Reflect.set(selected, "selectedAuthority", `${projectA}/stem/guitar`)).toBe( + false, + ); + expect(selected.selectedAuthority).toBe(`${projectA}/stem/drums`); + }); +}); diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.test.ts b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts new file mode 100644 index 000000000..852a1c4d0 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSession.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { + beginPlaybackSourceDiscovery, + completePlaybackSourceDiscovery, + createPlaybackSourceSession, + selectPlaybackSource, +} from "./playbackSourceSession"; + +const projectA = "bandscope-project://project-100-1"; +const projectB = "bandscope-project://project-200-1"; +const projectAOptions = [ + { kind: "full_mix" as const, authority: projectA }, + { kind: "vocals" as const, authority: `${projectA}/stem/vocals` }, + { kind: "bass" as const, authority: `${projectA}/stem/bass` }, + { kind: "drums" as const, authority: `${projectA}/stem/drums` }, + { kind: "other" as const, authority: `${projectA}/stem/other` }, +]; + +describe("playback source discovery session", () => { + it("starts from the current full mix without inventing stem availability", () => { + expect(createPlaybackSourceSession(projectA)).toEqual({ + fullMixAuthority: projectA, + options: [{ kind: "full_mix", authority: projectA }], + pendingRequest: null, + requestSequence: 0, + selectedAuthority: projectA, + }); + }); + + it("clears stale stems before a refresh can observe native revocation", () => { + let state = createPlaybackSourceSession(projectA); + const refresh = beginPlaybackSourceDiscovery(state, projectA); + state = completePlaybackSourceDiscovery(refresh.state, refresh.request, projectAOptions); + state = selectPlaybackSource(state, `${projectA}/stem/vocals`); + + const nextRefresh = beginPlaybackSourceDiscovery(state, projectA); + + expect(nextRefresh.state.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(nextRefresh.state.selectedAuthority).toBe(projectA); + }); + + it("ignores an older discovery after the full-mix authority rotates", () => { + const first = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + const rotated = beginPlaybackSourceDiscovery(first.state, projectB); + + const staleCompletion = completePlaybackSourceDiscovery( + rotated.state, + first.request, + projectAOptions, + ); + + expect(staleCompletion.fullMixAuthority).toBe(projectB); + expect(staleCompletion.options).toEqual([ + { kind: "full_mix", authority: projectB }, + ]); + expect(staleCompletion.pendingRequest).toEqual(rotated.request); + }); + + it("rejects a forged discovery receipt even when its scalar identity matches", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + expect(begin.request).not.toBeNull(); + const forgedRequest = begin.request + ? { + fullMixAuthority: begin.request.fullMixAuthority, + sequence: begin.request.sequence, + } + : null; + + const completed = completePlaybackSourceDiscovery( + begin.state, + forgedRequest, + projectAOptions, + ); + + expect(completed).toBe(begin.state); + expect(completed.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(completed.pendingRequest).toBe(begin.request); + }); + + it("fails closed when completion is partial, malformed, or project-mismatched", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + + for (const invalid of [ + projectAOptions.slice(0, 2), + [...projectAOptions, projectAOptions[1]], + [{ kind: "full_mix", authority: projectB }], + [{ kind: "vocals", authority: `${projectA}/stem/vocals/../private.wav` }], + null, + "not-an-option-list", + ]) { + const completed = completePlaybackSourceDiscovery( + begin.state, + begin.request, + invalid, + ); + expect(completed.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(completed.selectedAuthority).toBe(projectA); + expect(completed.pendingRequest).toBeNull(); + } + }); + + it("fails closed when hostile option inspection throws", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + const throwingGetter = Object.defineProperties({}, { + authority: { enumerable: true, value: projectA }, + kind: { + enumerable: true, + get: () => { + throw new Error("hostile kind getter"); + }, + }, + }); + const throwingProxy = new Proxy({}, { + getOwnPropertyDescriptor: () => { + throw new Error("hostile property trap"); + }, + }); + + for (const invalid of [[throwingGetter], [throwingProxy]]) { + expect(() => + completePlaybackSourceDiscovery(begin.state, begin.request, invalid), + ).not.toThrow(); + const completed = completePlaybackSourceDiscovery( + begin.state, + begin.request, + invalid, + ); + expect(completed.options).toEqual([ + { kind: "full_mix", authority: projectA }, + ]); + expect(completed.selectedAuthority).toBe(projectA); + expect(completed.pendingRequest).toBeNull(); + } + }); + + it("never reuses a discovery request identity after the safe-integer sequence is exhausted", () => { + const exhausted = { + ...createPlaybackSourceSession(projectA), + requestSequence: Number.MAX_SAFE_INTEGER, + }; + const next = beginPlaybackSourceDiscovery(exhausted, projectA); + + expect(next.request).toBeNull(); + expect(next.state).toEqual({ + fullMixAuthority: projectA, + options: [{ kind: "full_mix", authority: projectA }], + pendingRequest: null, + requestSequence: Number.MAX_SAFE_INTEGER, + selectedAuthority: projectA, + }); + + const ancientRequest = { fullMixAuthority: projectA, sequence: 1 }; + expect( + completePlaybackSourceDiscovery(next.state, ancientRequest, projectAOptions), + ).toEqual(next.state); + }); + + it("admits selection only from the latest canonical option set", () => { + const begin = beginPlaybackSourceDiscovery( + createPlaybackSourceSession(projectA), + projectA, + ); + const completed = completePlaybackSourceDiscovery( + begin.state, + begin.request, + projectAOptions, + ); + + expect( + selectPlaybackSource(completed, `${projectA}/stem/drums`).selectedAuthority, + ).toBe(`${projectA}/stem/drums`); + expect( + selectPlaybackSource(completed, `${projectA}/stem/guitar`).selectedAuthority, + ).toBe(projectA); + }); +}); diff --git a/apps/desktop/src/features/workspace/playbackSourceSession.ts b/apps/desktop/src/features/workspace/playbackSourceSession.ts new file mode 100644 index 000000000..b1f94bb9e --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSession.ts @@ -0,0 +1,250 @@ +import { + derivePlaybackSourceOptions, + type PlaybackSourceOption, + type PlaybackSourceKind, +} from "./playbackSourceSelection"; + +/** Identity of one in-flight native availability lookup. */ +export interface PlaybackSourceDiscoveryRequest { + fullMixAuthority: string; + sequence: number; +} + +/** Renderer-owned playback-source state for the current native project authority. */ +export interface PlaybackSourceSession { + fullMixAuthority: string | null; + options: PlaybackSourceOption[]; + pendingRequest: PlaybackSourceDiscoveryRequest | null; + requestSequence: number; + selectedAuthority: string | null; +} + +function fullMixOnly(authority: string): PlaybackSourceOption[] { + return [{ kind: "full_mix", authority }]; +} + +function freezePlaybackSourceOptions( + options: PlaybackSourceOption[], +): PlaybackSourceOption[] { + const frozenOptions = options.map((option) => + Object.freeze({ kind: option.kind, authority: option.authority }), + ); + return Object.freeze(frozenOptions) as PlaybackSourceOption[]; +} + +function freezePlaybackSourceDiscoveryRequest( + request: PlaybackSourceDiscoveryRequest | null, +): PlaybackSourceDiscoveryRequest | null { + if (request === null || Object.isFrozen(request)) { + return request; + } + return Object.freeze({ ...request }); +} + +function freezePlaybackSourceSession( + state: PlaybackSourceSession, +): PlaybackSourceSession { + return Object.freeze({ + ...state, + options: freezePlaybackSourceOptions(state.options), + pendingRequest: freezePlaybackSourceDiscoveryRequest(state.pendingRequest), + }) as PlaybackSourceSession; +} + +function isValidFullMixAuthority(authority: string | null | undefined): authority is string { + return ( + typeof authority === "string" && + derivePlaybackSourceOptions(authority, [authority]) !== null + ); +} + +function normalizeDiscoveredOptions( + fullMixAuthority: string, + discovered: unknown, +): PlaybackSourceOption[] | null { + if (!Array.isArray(discovered)) { + return null; + } + + const declared: Array<{ kind: PlaybackSourceKind; authority: string }> = []; + for (const candidate of discovered) { + if ( + typeof candidate !== "object" || + candidate === null || + !Object.hasOwn(candidate, "kind") || + !Object.hasOwn(candidate, "authority") + ) { + return null; + } + const kind = (candidate as { kind?: unknown }).kind; + const authority = (candidate as { authority?: unknown }).authority; + if ( + (kind !== "full_mix" && + kind !== "vocals" && + kind !== "bass" && + kind !== "drums" && + kind !== "other") || + typeof authority !== "string" + ) { + return null; + } + declared.push({ kind, authority }); + } + + const canonical = derivePlaybackSourceOptions( + fullMixAuthority, + declared.map((option) => option.authority), + ); + if ( + canonical === null || + canonical.length !== declared.length || + canonical.some( + (option, index) => + option.kind !== declared[index]?.kind || + option.authority !== declared[index]?.authority, + ) + ) { + return null; + } + return canonical; +} + +/** Start with only authority already owned by the mounted project. */ +export function createPlaybackSourceSession( + fullMixAuthority: string | null | undefined, +): PlaybackSourceSession { + if (!isValidFullMixAuthority(fullMixAuthority)) { + return freezePlaybackSourceSession({ + fullMixAuthority: null, + options: [], + pendingRequest: null, + requestSequence: 0, + selectedAuthority: null, + }); + } + return freezePlaybackSourceSession({ + fullMixAuthority, + options: fullMixOnly(fullMixAuthority), + pendingRequest: null, + requestSequence: 0, + selectedAuthority: fullMixAuthority, + }); +} + +/** + * Begin a refresh and immediately discard previously discovered stems. + * + * Native stem authority is revocable. Keeping old options visible while an async + * refresh runs would let a stale button outlive the authority snapshot that created it. + * Request identities never wrap: after the safe-integer sequence is exhausted the + * session stays full-mix-only until a new session is created, so an ancient receipt + * cannot become current again by colliding with a reused sequence number. + */ +export function beginPlaybackSourceDiscovery( + state: PlaybackSourceSession, + currentFullMixAuthority: string | null | undefined, +): { + state: PlaybackSourceSession; + request: PlaybackSourceDiscoveryRequest | null; +} { + const currentSequence = + Number.isSafeInteger(state.requestSequence) && state.requestSequence >= 0 + ? state.requestSequence + : Number.MAX_SAFE_INTEGER; + const nextSequence = + currentSequence < Number.MAX_SAFE_INTEGER ? currentSequence + 1 : null; + if (!isValidFullMixAuthority(currentFullMixAuthority)) { + return { + state: freezePlaybackSourceSession({ + fullMixAuthority: null, + options: [], + pendingRequest: null, + requestSequence: nextSequence ?? currentSequence, + selectedAuthority: null, + }), + request: null, + }; + } + + if (nextSequence === null) { + return { + state: freezePlaybackSourceSession({ + fullMixAuthority: currentFullMixAuthority, + options: fullMixOnly(currentFullMixAuthority), + pendingRequest: null, + requestSequence: currentSequence, + selectedAuthority: currentFullMixAuthority, + }), + request: null, + }; + } + + const request = Object.freeze({ + fullMixAuthority: currentFullMixAuthority, + sequence: nextSequence, + }) satisfies PlaybackSourceDiscoveryRequest; + return { + state: freezePlaybackSourceSession({ + fullMixAuthority: currentFullMixAuthority, + options: fullMixOnly(currentFullMixAuthority), + pendingRequest: request, + requestSequence: nextSequence, + selectedAuthority: currentFullMixAuthority, + }), + request, + }; +} + +/** Apply only the exact issued discovery receipt; malformed results stay full-mix only. */ +export function completePlaybackSourceDiscovery( + state: PlaybackSourceSession, + request: PlaybackSourceDiscoveryRequest | null, + discovered: unknown, +): PlaybackSourceSession { + if ( + request === null || + state.pendingRequest === null || + state.pendingRequest !== request || + state.pendingRequest.sequence !== request.sequence || + state.pendingRequest.fullMixAuthority !== request.fullMixAuthority || + state.fullMixAuthority !== request.fullMixAuthority + ) { + return state; + } + + let options: PlaybackSourceOption[]; + try { + options = + normalizeDiscoveredOptions(request.fullMixAuthority, discovered) ?? + fullMixOnly(request.fullMixAuthority); + } catch { + // Hostile getters/proxy traps cannot turn revoked availability into renderer state. + options = fullMixOnly(request.fullMixAuthority); + } + const selectedAuthority = options.some( + (option) => option.authority === state.selectedAuthority, + ) + ? state.selectedAuthority + : request.fullMixAuthority; + + return freezePlaybackSourceSession({ + ...state, + options, + pendingRequest: null, + selectedAuthority, + }); +} + +/** Select only an authority present in the current canonical option snapshot. */ +export function selectPlaybackSource( + state: PlaybackSourceSession, + authority: string, +): PlaybackSourceSession { + if (state.options.some((option) => option.authority === authority)) { + return freezePlaybackSourceSession({ ...state, selectedAuthority: authority }); + } + return freezePlaybackSourceSession({ + ...state, + selectedAuthority: state.fullMixAuthority, + }); +} diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts new file mode 100644 index 000000000..9b380cd7c --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.immutability.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { RehearsalTransportState } from "./rehearsalTransport"; +import { + admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, + createPlaybackSourceSwitchSession, +} from "./playbackSourceSwitch"; + +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const bassAuthority = `${fullMixAuthority}/stem/bass`; + +const loopingTransport: RehearsalTransportState = { + phase: "looping", + loop: { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, + }, + countInRemainingBeats: 0, + playheadSeconds: 37.25, + playbackRate: 0.75, +}; + +describe("playback source switch receipt immutability", () => { + it("does not let later renderer code rewrite an issued receipt or its session identity", () => { + const started = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + loopingTransport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + + expect(started.plan).not.toBeNull(); + expect(Object.isFrozen(started.plan)).toBe(true); + expect(Object.isFrozen(started.state)).toBe(true); + expect( + Reflect.set(started.plan as object, "targetAuthority", bassAuthority), + ).toBe(false); + expect(Reflect.set(started.plan as object, "seekSeconds", 44)).toBe(false); + expect(Reflect.set(started.state as object, "sequence", 99)).toBe(false); + + expect(started.plan?.targetAuthority).toBe(vocalsAuthority); + expect(started.plan?.seekSeconds).toBe(37.25); + expect(started.state.sequence).toBe(1); + expect( + admitPlaybackSourceSwitchTarget( + started.state, + started.plan, + 45, + vocalsAuthority, + ), + ).toBe(started.plan); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.pausedCountIn.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.pausedCountIn.test.ts new file mode 100644 index 000000000..a5a5c8f97 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.pausedCountIn.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import type { + RehearsalLoopWindow, + RehearsalTransportState, +} from "./rehearsalTransport"; +import { capturePlaybackSourceSwitch } from "./playbackSourceSwitch"; + +const loop: RehearsalLoopWindow = { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, +}; + +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; + +describe("playback source switch paused-count-in admission", () => { + it("does not mint a restoration receipt while a paused count-in still owns pending beats", () => { + const pausedCountIn: RehearsalTransportState = { + phase: "paused", + loop, + countInRemainingBeats: 2, + playheadSeconds: 37.25, + playbackRate: 0.75, + }; + + expect( + capturePlaybackSourceSwitch(pausedCountIn, 37.25, { + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 1, + }), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts new file mode 100644 index 000000000..1e8a1d380 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from "vitest"; +import type { + RehearsalLoopWindow, + RehearsalTransportState, +} from "./rehearsalTransport"; +import { + admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, + capturePlaybackSourceSwitch, + createPlaybackSourceSwitchSession, +} from "./playbackSourceSwitch"; + +const loop: RehearsalLoopWindow = { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, +}; + +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const bassAuthority = `${fullMixAuthority}/stem/bass`; + +function transport( + phase: RehearsalTransportState["phase"], +): RehearsalTransportState { + return { + phase, + loop, + countInRemainingBeats: phase === "counting-in" ? 3 : 0, + playheadSeconds: phase === "armed" ? loop.startSeconds : 37.25, + playbackRate: 0.75, + }; +} + +function capture( + phase: RehearsalTransportState["phase"], + mediaTime: number, + targetAuthority = vocalsAuthority, + sequence = 3, +) { + return capturePlaybackSourceSwitch(transport(phase), mediaTime, { + sourceAuthority: fullMixAuthority, + targetAuthority, + sequence, + }); +} + +describe("playback source switch continuity", () => { + it("captures exact looping position, playback rate, and target identity for resume after target metadata", () => { + expect(capture("looping", 37.25)).toEqual({ + loopStartSeconds: 30, + loopEndSeconds: 45, + seekSeconds: 37.25, + playbackRate: 0.75, + sourcePhase: "looping", + resumeAfterLoad: true, + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, + }); + }); + + it("preserves a paused position without manufacturing playback intent", () => { + expect(capture("paused", 36.5)).toEqual({ + loopStartSeconds: 30, + loopEndSeconds: 45, + seekSeconds: 36.5, + playbackRate: 0.75, + sourcePhase: "paused", + resumeAfterLoad: false, + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, + }); + }); + + it("uses the selected loop start when switching an armed transport", () => { + expect(capture("armed", Number.NaN)).toEqual({ + loopStartSeconds: 30, + loopEndSeconds: 45, + seekSeconds: 30, + playbackRate: 0.75, + sourcePhase: "armed", + resumeAfterLoad: false, + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, + }); + }); + + it.each(["idle", "counting-in"] as const)( + "fails closed instead of changing source during %s", + (phase) => { + expect(capture(phase, 37.25)).toBeNull(); + }, + ); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, 29.99, 45, 90])( + "rejects an out-of-loop media position instead of silently clamping it: %s", + (mediaTime) => { + expect(capture("looping", mediaTime)).toBeNull(); + expect(capture("paused", mediaTime)).toBeNull(); + }, + ); + + it.each([ + { startSeconds: Number.NaN, endSeconds: 45 }, + { startSeconds: 30, endSeconds: Number.NaN }, + { startSeconds: Number.NEGATIVE_INFINITY, endSeconds: 45 }, + { startSeconds: -1, endSeconds: 45 }, + { startSeconds: 45, endSeconds: 45 }, + { startSeconds: 46, endSeconds: 45 }, + ])( + "rejects malformed loop timing before issuing restoration authority: %o", + ({ startSeconds, endSeconds }) => { + const malformedTransport: RehearsalTransportState = { + ...transport("looping"), + loop: { ...loop, startSeconds, endSeconds }, + }; + expect( + capturePlaybackSourceSwitch(malformedTransport, 37.25, { + sourceAuthority: fullMixAuthority, + targetAuthority: vocalsAuthority, + sequence: 3, + }), + ).toBeNull(); + }, + ); + + it.each([ + { targetAuthority: fullMixAuthority, sequence: 3 }, + { targetAuthority: vocalsAuthority, sequence: 0 }, + { targetAuthority: vocalsAuthority, sequence: Number.MAX_SAFE_INTEGER + 1 }, + ])("rejects a no-op or invalid switch identity: %o", ({ targetAuthority, sequence }) => { + expect(capture("looping", 37.25, targetAuthority, sequence)).toBeNull(); + }); + + it.each([ + ["file:///private/source.wav", vocalsAuthority], + [fullMixAuthority, "https://example.com/reference.wav"], + [fullMixAuthority, "bandscope-project://project-99-1/stem/vocals"], + [`${fullMixAuthority}/stem/guitar`, vocalsAuthority], + ])( + "rejects non-canonical or cross-project source-switch authority: %s -> %s", + (sourceAuthority, targetAuthority) => { + expect( + capturePlaybackSourceSwitch(transport("looping"), 37.25, { + sourceAuthority, + targetAuthority, + sequence: 3, + }), + ).toBeNull(); + }, + ); + + it("admits a target only when its decoded duration and switch receipt still match the active target", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + expect(begun.plan).not.toBeNull(); + + expect( + admitPlaybackSourceSwitchTarget(begun.state, begun.plan, 45, vocalsAuthority), + ).toBe(begun.plan); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 44.999, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 37.25, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + Number.NaN, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + Number.POSITIVE_INFINITY, + vocalsAuthority, + ), + ).toBeNull(); + }); + + it("rejects a copied switch plan instead of treating equal scalar fields as an issued active receipt", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + expect(begun.plan).not.toBeNull(); + const copiedPlan = Object.freeze({ ...begun.plan! }); + + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + copiedPlan, + 45, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 45, + vocalsAuthority, + ), + ).toBe(begun.plan); + }); + + it("rejects stale loadedmetadata receipts after a newer source switch supersedes the target", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport("looping"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect( + admitPlaybackSourceSwitchTarget( + second.state, + first.plan, + 45, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + second.state, + first.plan, + 45, + bassAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + second.state, + second.plan, + 45, + bassAuthority, + ), + ).toBe(second.plan); + }); + + it("invalidates the prior media receipt as soon as a newer source switch begins", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport("looping"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect(first.plan?.sequence).toBe(1); + expect(second.plan?.sequence).toBe(2); + expect(second.state.activePlan).toBe(second.plan); + expect( + admitPlaybackSourceSwitchTarget( + second.state, + first.plan, + 45, + vocalsAuthority, + ), + ).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + second.state, + second.plan, + 45, + bassAuthority, + ), + ).toBe(second.plan); + }); + + it("burns a switch identity even when the newer attempt cannot produce a continuity plan", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport("looping"), + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const rejected = beginPlaybackSourceSwitch( + first.state, + transport("counting-in"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect(rejected.plan).toBeNull(); + expect(rejected.state.sequence).toBe(2); + expect(rejected.state.activePlan).toBeNull(); + expect( + admitPlaybackSourceSwitchTarget( + rejected.state, + first.plan, + 45, + vocalsAuthority, + ), + ).toBeNull(); + }); + + it("fails closed without reusing a switch receipt after sequence exhaustion", () => { + const exhausted = { + sequence: Number.MAX_SAFE_INTEGER, + activePlan: capture("looping", 37.25, vocalsAuthority, Number.MAX_SAFE_INTEGER), + }; + const result = beginPlaybackSourceSwitch( + exhausted, + transport("looping"), + 37.25, + fullMixAuthority, + bassAuthority, + ); + + expect(result.plan).toBeNull(); + expect(result.state).toEqual({ + sequence: Number.MAX_SAFE_INTEGER, + activePlan: null, + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitch.ts b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts new file mode 100644 index 000000000..c39871214 --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitch.ts @@ -0,0 +1,228 @@ +import { + isRehearsalPlaybackRate, + type RehearsalPlaybackRate, + type RehearsalTransportPhase, + type RehearsalTransportState, +} from "./rehearsalTransport"; +import { playbackSourceProjectId } from "./playbackSourceSelection"; + +/** Identity of one renderer-owned media-source replacement attempt. */ +export interface PlaybackSourceSwitchIdentity { + sourceAuthority: string; + targetAuthority: string; + sequence: number; +} + +/** Transport continuity that must survive one admitted playback-source change. */ +export interface PlaybackSourceSwitchPlan extends PlaybackSourceSwitchIdentity { + loopStartSeconds: number; + loopEndSeconds: number; + seekSeconds: number; + playbackRate: RehearsalPlaybackRate; + sourcePhase: Extract; + resumeAfterLoad: boolean; +} + +/** Renderer-owned identity state for the mutable HTML media source lifecycle. */ +export interface PlaybackSourceSwitchSession { + sequence: number; + activePlan: PlaybackSourceSwitchPlan | null; +} + +function hasValidSwitchIdentity(identity: PlaybackSourceSwitchIdentity): boolean { + const sourceProjectId = playbackSourceProjectId(identity.sourceAuthority); + const targetProjectId = playbackSourceProjectId(identity.targetAuthority); + return ( + sourceProjectId !== null && + sourceProjectId === targetProjectId && + identity.sourceAuthority !== identity.targetAuthority && + Number.isSafeInteger(identity.sequence) && + identity.sequence > 0 + ); +} + +function freezePlaybackSourceSwitchSession( + sequence: number, + activePlan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + return Object.freeze({ sequence, activePlan }); +} + +function retireExactPlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + plan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + if ( + plan === null || + state.activePlan === null || + state.activePlan !== plan || + state.sequence !== plan.sequence + ) { + return state; + } + return freezePlaybackSourceSwitchSession(state.sequence, null); +} + +/** Start a renderer switch session with no reusable media receipt. */ +export function createPlaybackSourceSwitchSession(): PlaybackSourceSwitchSession { + return freezePlaybackSourceSwitchSession(0, null); +} + +/** + * Capture transport continuity before replacing the media source. + * + * Count-in changes are deliberately rejected: changing media while the independent + * count-in clock is running would create a second timing race. A paused transport + * that still owns pending count-in beats is the same timing state and therefore + * also fails closed. Looping/paused-after-loop switches retain the exact admitted + * media position; armed switches start from the selected loop boundary. Invalid + * loop timing, positions, or switch identities fail closed rather than being + * clamped or converted into an ambiguous no-op. Source and target must both be + * canonical opaque authorities for the same mounted playback project. + */ +export function capturePlaybackSourceSwitch( + transport: RehearsalTransportState, + currentMediaTimeSeconds: number, + identity: PlaybackSourceSwitchIdentity, +): PlaybackSourceSwitchPlan | null { + const loop = transport.loop; + if ( + !loop || + !Number.isFinite(loop.startSeconds) || + !Number.isFinite(loop.endSeconds) || + loop.startSeconds < 0 || + loop.endSeconds <= loop.startSeconds || + !isRehearsalPlaybackRate(transport.playbackRate) || + !hasValidSwitchIdentity(identity) || + (transport.phase === "paused" && transport.countInRemainingBeats !== 0) || + (transport.phase !== "armed" && + transport.phase !== "looping" && + transport.phase !== "paused") + ) { + return null; + } + + const seekSeconds = + transport.phase === "armed" ? loop.startSeconds : currentMediaTimeSeconds; + if ( + !Number.isFinite(seekSeconds) || + seekSeconds < loop.startSeconds || + seekSeconds >= loop.endSeconds + ) { + return null; + } + + return Object.freeze({ + ...identity, + loopStartSeconds: loop.startSeconds, + loopEndSeconds: loop.endSeconds, + seekSeconds, + playbackRate: transport.playbackRate, + sourcePhase: transport.phase, + resumeAfterLoad: transport.phase === "looping", + }); +} + +/** + * Begin one media-source replacement and invalidate every older metadata receipt. + * + * The sequence is burned before continuity capture. A rejected target or transport + * phase therefore cannot leave an older `loadedmetadata` receipt authoritative. + * Sequence values never wrap; exhaustion clears the active plan until the player + * mounts a fresh switch session. Issued receipts and session identities are frozen + * so later renderer code cannot rewrite what a future metadata event is allowed to + * restore. + */ +export function beginPlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + transport: RehearsalTransportState, + currentMediaTimeSeconds: number, + sourceAuthority: string, + targetAuthority: string, +): { state: PlaybackSourceSwitchSession; plan: PlaybackSourceSwitchPlan | null } { + const currentSequence = + Number.isSafeInteger(state.sequence) && state.sequence >= 0 + ? state.sequence + : Number.MAX_SAFE_INTEGER; + if (currentSequence >= Number.MAX_SAFE_INTEGER) { + return { + state: freezePlaybackSourceSwitchSession(Number.MAX_SAFE_INTEGER, null), + plan: null, + }; + } + + const sequence = currentSequence + 1; + const plan = capturePlaybackSourceSwitch( + transport, + currentMediaTimeSeconds, + { + sourceAuthority, + targetAuthority, + sequence, + }, + ); + return { + state: freezePlaybackSourceSwitchSession(sequence, plan), + plan, + }; +} + +/** + * Admit decoded target metadata only for the exact active switch receipt. + * + * `loadedmetadata` belongs to a mutable media element rather than to the source that + * initiated the event. The caller therefore supplies the current renderer switch + * session, and admission requires exact issued-plan identity as well as target, + * sequence, and duration coverage. A frozen look-alike object with identical scalar + * fields is not authority and cannot restore transport state. + */ +export function admitPlaybackSourceSwitchTarget( + state: PlaybackSourceSwitchSession, + plan: PlaybackSourceSwitchPlan | null, + targetDurationSeconds: number, + currentTargetAuthority: string, +): PlaybackSourceSwitchPlan | null { + if ( + !plan || + state.activePlan === null || + state.activePlan !== plan || + state.sequence !== plan.sequence || + !Number.isFinite(targetDurationSeconds) || + targetDurationSeconds <= 0 || + plan.targetAuthority !== currentTargetAuthority || + !Number.isSafeInteger(state.sequence) || + state.sequence <= 0 || + plan.seekSeconds >= targetDurationSeconds || + plan.loopEndSeconds > targetDurationSeconds + ) { + return null; + } + return plan; +} + +/** + * Retire one admitted media-switch receipt without letting a stale or copied plan + * clear a newer target. The caller may invoke this only after target admission; + * premature retirement fails safe by removing restoration authority, never by + * granting playback authority. + */ +export function completePlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + admittedPlan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + return retireExactPlaybackSourceSwitch(state, admittedPlan); +} + +/** + * Retire one failed media-switch receipt after target loading or admission fails. + * + * A failed target must not leave restoration authority alive for a later metadata + * event from the same mutable media element. Exact issued-object identity prevents + * a copied or stale failure receipt from cancelling a newer switch. + */ +export function abortPlaybackSourceSwitch( + state: PlaybackSourceSwitchSession, + failedPlan: PlaybackSourceSwitchPlan | null, +): PlaybackSourceSwitchSession { + return retireExactPlaybackSourceSwitch(state, failedPlan); +} diff --git a/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts new file mode 100644 index 000000000..1de42005f --- /dev/null +++ b/apps/desktop/src/features/workspace/playbackSourceSwitchCompletion.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { + RehearsalLoopWindow, + RehearsalTransportState, +} from "./rehearsalTransport"; +import { + abortPlaybackSourceSwitch, + admitPlaybackSourceSwitchTarget, + beginPlaybackSourceSwitch, + completePlaybackSourceSwitch, + createPlaybackSourceSwitchSession, +} from "./playbackSourceSwitch"; + +const loop: RehearsalLoopWindow = { + sourceIndex: 0, + selectionKey: "section-1:0", + sectionId: "section-1", + sectionLabel: "Verse 1", + startSeconds: 30, + endSeconds: 45, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4, +}; + +const fullMixAuthority = "bandscope-project://project-42-7"; +const vocalsAuthority = `${fullMixAuthority}/stem/vocals`; +const bassAuthority = `${fullMixAuthority}/stem/bass`; + +const transport: RehearsalTransportState = { + phase: "looping", + loop, + countInRemainingBeats: 0, + playheadSeconds: 37.25, + playbackRate: 0.75, +}; + +describe("playback source switch completion", () => { + it("retires only the exact active receipt after target metadata is admitted", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const admitted = admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 45, + vocalsAuthority, + ); + expect(admitted).toBe(begun.plan); + + const completed = completePlaybackSourceSwitch(begun.state, admitted); + + expect(completed).toEqual({ sequence: 1, activePlan: null }); + expect(Object.isFrozen(completed)).toBe(true); + expect(completePlaybackSourceSwitch(begun.state, { ...begun.plan! })).toBe( + begun.state, + ); + }); + + it("does not let an admitted stale receipt clear a newer active switch", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const staleAdmitted = admitPlaybackSourceSwitchTarget( + first.state, + first.plan, + 45, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport, + 37.25, + vocalsAuthority, + bassAuthority, + ); + + expect(completePlaybackSourceSwitch(second.state, staleAdmitted)).toBe( + second.state, + ); + expect(second.state.activePlan).toBe(second.plan); + }); + + it("retires only the exact active receipt when target metadata fails admission", () => { + const begun = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + expect( + admitPlaybackSourceSwitchTarget( + begun.state, + begun.plan, + 40, + vocalsAuthority, + ), + ).toBeNull(); + + const aborted = abortPlaybackSourceSwitch(begun.state, begun.plan); + + expect(aborted).toEqual({ sequence: 1, activePlan: null }); + expect(Object.isFrozen(aborted)).toBe(true); + expect(abortPlaybackSourceSwitch(begun.state, { ...begun.plan! })).toBe( + begun.state, + ); + }); + + it("does not let a stale failed receipt clear a newer active switch", () => { + const first = beginPlaybackSourceSwitch( + createPlaybackSourceSwitchSession(), + transport, + 37.25, + fullMixAuthority, + vocalsAuthority, + ); + const second = beginPlaybackSourceSwitch( + first.state, + transport, + 37.25, + vocalsAuthority, + bassAuthority, + ); + + expect(abortPlaybackSourceSwitch(second.state, first.plan)).toBe(second.state); + expect(second.state.activePlan).toBe(second.plan); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/locales/en/playback-source.json b/apps/desktop/src/locales/en/playback-source.json new file mode 100644 index 000000000..34f1f24cc --- /dev/null +++ b/apps/desktop/src/locales/en/playback-source.json @@ -0,0 +1,12 @@ +{ + "legend": "Playback source", + "loading": "Checking playback sources…", + "empty": "No stem sources are available for this project. Full mix is ready.", + "error": "Could not check stem sources. Full mix is still available.", + "retry": "Check stem sources again", + "fullMix": "Full mix", + "vocals": "Vocals", + "bass": "Bass", + "drums": "Drums", + "other": "Other instruments" +} diff --git a/apps/desktop/src/locales/ko/playback-source.json b/apps/desktop/src/locales/ko/playback-source.json new file mode 100644 index 000000000..c2013823a --- /dev/null +++ b/apps/desktop/src/locales/ko/playback-source.json @@ -0,0 +1,12 @@ +{ + "legend": "재생 소스", + "loading": "재생 소스를 확인하는 중…", + "empty": "이 프로젝트에는 재생할 수 있는 스템이 없습니다. 전체 믹스는 바로 재생할 수 있습니다.", + "error": "스템 소스를 확인하지 못했습니다. 전체 믹스는 계속 재생할 수 있습니다.", + "retry": "스템 소스 다시 확인", + "fullMix": "전체 믹스", + "vocals": "보컬", + "bass": "베이스", + "drums": "드럼", + "other": "그 외 악기" +} diff --git a/docs/engineering/local-project-format.md b/docs/engineering/local-project-format.md index 4c4368f2c..375da8a2c 100644 --- a/docs/engineering/local-project-format.md +++ b/docs/engineering/local-project-format.md @@ -1,55 +1,64 @@ # Local Project Format -This document specifies the format and lifecycle of a BandScope `.bscope` project file, focusing on data persistence, manual overrides, and recovery. +This document describes the project data that BandScope currently persists in a `.bscope` file and the security boundary around that file. The versioned crash-safe format, autosave, migration, backup, and recovery authority remains #962. -## Overview +## Current persisted payload -BandScope projects are saved as `.bscope` files. These files are standard JSON containing the serialized `RehearsalSong` data structure. They allow users to persist the results of audio analysis and their manual corrections (overrides) across sessions. +A `.bscope` file is JSON containing the serialized `RehearsalSong` contract from `@bandscope/shared-types`. The renderer validates the song before Save, and the native save/load boundary independently deserializes the same allowed fields. Neither layer may silently drop a field accepted by the other. -## Schema - -The primary data structure for a `.bscope` file is the `RehearsalSong` type from `@bandscope/shared-types`. - -### Top-Level Structure +The current top level is: ```json { "id": "string", "title": "string", + "tempo": 120, "sections": [ ... ], "exportSummary": { "format": "cue-sheet", "headline": "string", "focusSections": ["string"] - } + }, + "collaboration": { + "syncMode": "local_only", + "syncNote": "string", + "assignments": [ ... ], + "comments": [ ... ], + "approvals": [ ... ] + }, + "scoreAttachments": [ + { "id": "uuid", "fileName": "score.pdf" } + ] } ``` -### Sections and Roles +`tempo`, `collaboration`, and `scoreAttachments` are optional. Absence remains valid for older files supported by the current contract. + +## Sections, roles, and provenance -Sections describe structural segments of the song (e.g., Intro, Verse, Chorus). Each section contains a list of roles (instruments or vocals). +Each section carries an exact integer `timeRange` with `end > start`, confidence/provenance, roles, and the part handoff graph. A role may additionally carry the current optional rehearsal fields `harmonicExplanation`, `transpositionPlan`, `transcription`, and `practiceProgress`. ```json { - "id": "section-id", + "id": "verse-1", "label": "verse", "groove": "string", + "timeRange": { "start": 10, "end": 30 }, "confidence": { - "level": "high|medium|low", - "source": "model|user", + "level": "medium", + "source": "model", "notes": "string" }, - "roles": [ ... ] + "roles": [ ... ], + "partGraph": [ ... ] } ``` -### Manual Overrides - -To ensure provenance preservation, BandScope records when a user manually changes an analyzed property. This is stored in the `manualOverrides` array on the `RehearsalRole` object. +BandScope records user-corrected harmony in the role's `manualOverrides` array instead of overwriting provenance invisibly: ```json { - "id": "role-id", + "id": "bass-guitar", "name": "Bass Guitar", "harmony": { "chord": "C#m7", @@ -66,20 +75,27 @@ To ensure provenance preservation, BandScope records when a user manually change }, "source": "user" } - ], - ... + ] } ``` -By retaining `manualOverrides`, BandScope can distinguish between original model outputs and user corrections, meeting the provenance requirements for the product. +Score attachment bytes are not embedded in this JSON. The project stores only the app-minted score id and display file name; native storage owns the PDF bytes separately. + +## Security constraints + +`.bscope` is untrusted user input. The current boundary applies these constraints: + +- Tauri refuses project files larger than 5 MiB before JSON parsing. +- Shared TypeScript validation and the native Rust DTO use explicit allowed fields; native structs keep `deny_unknown_fields` instead of accepting arbitrary JSON. +- A malformed or incomplete payload fails closed rather than executing code or best-effort dropping fields. +- Project JSON does not gain arbitrary filesystem access. Playback-source authorities and renderer-local source-switch receipts are volatile runtime state and are not persisted by this format. + +The structural allowlist is not yet the complete commercial durability policy. #962 still owns explicit collection/string/nesting limits, duplicate/cycle rules, portable source references, platform-semantic equivalence, and filesystem fault handling. -## Security Constraints +## Compatibility and crash-safety status -When loading `.bscope` files from disk, BandScope applies the following constraints: -1. **Size Limits**: The project file must not exceed an upper bound (currently enforced at 5MB in Tauri backend) to prevent memory exhaustion. -2. **Schema Validation**: The loaded JSON is structurally validated against the `RehearsalSong` contract. -3. **Bounded Processing**: The JSON parsing is standard and safe, avoiding arbitrary code execution or payload expansion attacks. +Current additive optional fields remain readable when absent, and legacy sections missing `timeRange` receive only the existing narrowly defined renderer migration path. There is not yet an independent `project_format_version` with ordered migration receipts. -## Extensibility +The native Save path is also not yet crash-safe publication: atomic staging, required flushes, validation before replace, known-good backup, autosave, recovery snapshots, interrupted migration behavior, and fault injection remain open acceptance criteria in #962. A successful ordinary Save/Load round trip must therefore not be described as crash/power-loss durability evidence. -Future updates to the `.bscope` format should be backward-compatible where possible, adding new fields to the `RehearsalSong` contract rather than breaking existing fields. If structural changes are required, a format version field may be introduced. +`docs/traceability/project-persistence-contract-parity.md` records the 2026-09-05 repair that brought the native payload back into structural parity with fields already accepted by `RehearsalSong`. diff --git a/docs/traceability/mounted-playback-source-selector.md b/docs/traceability/mounted-playback-source-selector.md new file mode 100644 index 000000000..32b9ad7ba --- /dev/null +++ b/docs/traceability/mounted-playback-source-selector.md @@ -0,0 +1,88 @@ +# Mounted playback-source selector traceability + +Status: Draft implementation evidence for PR #1160. This document does not promote the stack to shipped or release-ready state. + +## Problem + +The native playback boundary already exposed a renderer-safe availability command and the renderer already had canonical option, session, discovery, and source-switch receipt contracts. The mounted `RehearsalPlayer`, however, still consumed only the full-mix `audioSourcePath`. A rehearsing musician therefore had no buyer-visible way to choose the atomically admitted vocals, bass, drums, or other-instruments source even when native authority had registered the complete stem set. + +The selector must not create a second playback authority. It may display only the current native project's opaque authorities, must not expose filesystem paths, and must fail closed when native availability is partial, malformed, stale, revoked, or from another project. + +## Test-first evidence + +RED commit `6a9f892f08d5ebc7c8e67bb7372401f3d64b5e58` adds mounted-component regressions requiring all of the following: + +- `get_playback_source_availability` is called with only the current opaque full-mix authority; +- the complete atomic set is rendered in canonical `Full mix`, `Vocals`, `Bass`, `Drums`, `Other instruments` order; +- selecting vocals routes only the opaque stem authority through the existing `bandscope-playback` URL conversion boundary; +- partial native availability never becomes buyer-selectable stem UI. + +The pre-existing player had no source-selector group and no native availability call, so that contract was intentionally RED at the source level. No hosted CI receipt existed for that RED head. + +## Minimal mounted composition + +Causal implementation commit `88ded97b67f6b63bdccacd7226c3b9518b668e9b` keeps `RehearsalPlayer` as the public Workspace entry point and moves the existing transport implementation verbatim to `RehearsalPlayerCore`. The public wrapper owns only renderer-safe availability/session projection and passes one selected opaque authority into the existing transport owner. + +The wrapper starts each project with full mix only, calls `beginPlaybackSourceDiscovery`, invokes the existing renderer-safe discovery boundary, completes only the exact issued discovery receipt, and lets `PlaybackSourceSession` decide whether an option is current. It renders native radio controls only when the canonical snapshot contains more than full mix. Partial or invalid discovery therefore leaves the existing full-mix player usable without advertising stems. + +Native radio semantics were chosen over a bespoke segmented-control state machine because pointer, touch, keyboard selection, checked state, and accessible naming are already defined by the platform. Styling is deliberately subordinate to the existing rehearsal surface rather than introducing another decorative card or generic dashboard pattern. + +## Project-rotation race + +The first mounted composition exposed a narrower stale-render window: when the parent changed from project A to project B, React could render once with project A's previously discovered session before the effect reset it. That could briefly pass A's selected stem into the transport child or leave A's source choices visible while B was already the mounted full-mix authority. + +Regression commit `ad96e16ac54246d1dd70922ecd64f262412dc713` adds a delayed project-A discovery and a project-B rotation, then requires the late A result never to repopulate B's selector. Review of that lifecycle identified the synchronous render window as an additional finding. + +Causal fix `71c03bcc12de4806804d93db45e1d8f0ea764668` now treats a session as renderable only when `sourceSession.fullMixAuthority === audioSourcePath`. During project rotation the wrapper immediately hides the old option snapshot and passes the newly mounted full-mix authority to the transport child before asynchronous discovery begins. The existing exact-request completion and effect cancellation remain the second line of stale-response defense. + +## Multi-mount radio isolation + +Review after mounting also found that a constant HTML radio `name` would join source controls from two independently mounted rehearsal players into one browser radio group. That does not occur in the current single Workspace surface, but it is an invalid reusable-component contract and can make selecting a source in one mount visually uncheck another mount without changing its React authority state. + +RED commit `6e928262d8bbcba0845fcb04a1dc09c095e23434` adds two independently mounted players and requires both full-mix radios to remain selected until their own component changes. Causal fix `29b51d7778624ec0d887f256a10fc93420220971` scopes the native radio `name` with React `useId()`. Browser keyboard/radio semantics remain native while independent component instances no longer share selection state. + +A focused TypeScript 5.8.3 `--strict` compile of the exact public wrapper with contract-compatible stubs passed after the fix. This checks the new wrapper's type/syntax surface only; it is not repository exact-head CI evidence and does not substitute for the real workspace test suite. + +## Discovery waiting-state accessibility + +Fresh mounted review found that `beginPlaybackSourceDiscovery` correctly removes stale stem controls while native availability is pending, but the UI gave no visible or programmatically determinable explanation for that temporary disappearance. The same silent waiting state occurred on first discovery and after error-driven revocation refresh. This is a buyer-visible loading-state gap rather than a playback-authority gap: full mix remains usable, but the user should be told why stem choices are temporarily unavailable. + +RED commit `c55e15d1884fcb8cfa750ed3434b498917858bbb` holds native availability unresolved and requires the mounted player to expose `Checking playback sources…` as a `status` while the canonical stem snapshot is pending, with no premature stem radio group. Causal fix `1e2eb0b1654a368286e05b242680d14b0b147020` derives the waiting state only from the current project's exact `PlaybackSourceSession.pendingRequest`, adds EN/KO screen copy under the existing locale owner, and removes the status when that exact discovery settles. It does not add a spinner, new request store, polling loop, or synthetic stem option. + +WAI-ARIA defines `status` as an advisory live region whose implicit `aria-live` value is `polite` and whose implicit `aria-atomic` value is `true`. WCAG 2.2 Success Criterion 4.1.3 requires status messages about application waiting/progress states to be programmatically determinable without moving focus. W3C's ARIA22 technique additionally notes that some environments do not reliably treat `status` as atomic by default. Compatibility RED `ff7b418633cdf053d6a9c46d00282590bb7876ec` therefore requires explicit `aria-atomic="true"`; causal fix `b9592814a24969ff65176e45b545e660429323c2` adds that compatibility attribute without changing focus or escalating the message to an interruptive `alert`. + +Exact-head review then found a test-evidence defect rather than a production-authority defect: `RehearsalPlayerCore` already owns a separate persistent `role="status"`, so an unqualified `findByRole("status")` could bind to the wrong live region and an assertion that all statuses disappear could never be valid. Repair commit `1fafe1fb391c8d31fa55538b3b8243804cbb0276` targets only the discovery status by its exact loading copy plus `role="status"`, and after settlement asserts only that specific status is absent. The production status semantics and source authority are unchanged. + +The status copy remains presentation only. It cannot select a source, mint authority, prolong a receipt, or make a partial stem set visible. JA/ZH/VI/ES/DE/FR and the DB-backed versioned translation ledger remain the wider #965/product localization owner rather than being duplicated here. + +### Accessibility references + +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (n.d.). *Understanding Success Criterion 4.1.3: Status messages*. Retrieved September 5, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/status-messages + +World Wide Web Consortium. (n.d.). *ARIA22: Using `role=status` to present status messages*. Retrieved September 5, 2026, from https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA22 + +## Mounted revocation and reselection + +Native `PlaybackAuthority` deliberately revokes the prior generated stem set when a newer stem analysis starts or when authority moves to another generation. The mounted selector therefore cannot treat a previously discovered stem as durable just because its radio option is still in renderer state. + +Fresh RED commit `4e9276c7e782add0ef02a1f6a7435bb93eb7af43` selects `Vocals`, raises an actual media `error` from the mounted rehearsal `