-- SOUND SOURCE -- -- Parent Script -- ---------------------------------------------------------------------- -- 050523 JN: Added #play property with values #loop|#multiple|#once -- 060425 JN: Allowed pTransform to be set to a 3D object, so that -- its worldTransform can be obtained, even if it is -- the child of another object. Pan and volume now alters -- as the source moves. -- 060423 JN: version 0.1 ---------------------------------------------------------------------- -- An instance of this script can be used to locate a given sound -- source in space. Each sound has: -- -- * A symbol name (e.g. #NPC_1) -- * A property list of sound members (e.g. [#song: member "Song]). -- This allows the same source to be used for a series of different -- sounds, which may be played by name or at random. -- * A transform localising the sound in 3D space. This may be the -- transform of a moving 3D object in which case moving the object -- will move the sound. -- * A radius. Sounds are only heard if the player is inside this -- radius. They are loudest at the centre and diminish as the -- square of the distance from the centre. If a radius of 0 is -- used, the sound does not diminish. -- * An optional list of zones. If this list is not empty and the -- player is not currently in one of the zones listed, then the -- sound will not play -- * A maximum volume. This is the volume heard when the distance -- between the player and the sound source is 0, or when a radius -- is set to 0. -- * An optional channel. If no channel is attributed, the sound -- will not play -- -- As the position of the sound source relative to a player transform -- changes, so the volume and pan of the sound alters. -- -- QUESTION: Should this object manage the soundChannel, or should it -- simply inform the Sound Broker of the settings the sound channel -- would have, were the sound to be deemed worthy of a channel? -- -- Sounds may loop, or play all the way through then stop. If they -- stop then the channel becomes available when they stop. -- -- Sources may be: -- * A looping sound from a point source (e.g. a torch) -- * A looping sound produced by an area or volume (e.g. river) -- * A one-off sound, which may be repeated (e.g. gun shot) -- * A one-off sound which will not be repeated (e.g. start up chime) -- * A sound repeated at irregular intervals from random places -- (e.g. crickets) -- * A sound synchronised with an action (e.g. footsteps) -- * A continuous background sound (e.g. music or speech) -- * A sound which must play to the end (e.g. voiceover instructions) -- ------------------------------------------------------------------- -- PROPERTY DECLARATIONS -- property pSymbol -- unique symbol for a Sound Source instance property pMember -- 0 or sound member currently selected property pChannel -- 0 or sound object used to play this source property plSoundMembers -- [: , ...] property pTransform -- transform or 3D object defining position of -- sound source property pRadius -- positive number radius in which sound can -- be heard, or 0 = same volume everywhere property plZones -- list of zone symbols where sound is audible property pMaxVolume -- integer from 0 - 255 for maximum volume property pPlayType -- #loop | #multiple | #once property pPriority -- non-negative integer: 0 = lowest priority property pWorldSpace -- TRUE if pTransform is a transform rather -- than a 3D object which might be the child -- of another object. property pVolume -- last calculated volume of this source property pPan -- last calculated pan of this source property pLastTime -- set to the value of pChannel.currentTime -- when the sound is paused or suspended property pSuspendTime -- the milliseconds when the sound was -- suspended, or 0 if the sound is paused -- CONSTRUCTOR/DESCRUCTOR METHODS -- on new(me, aPropList) ------------------------------------------------ -- SOURCE: -- INPUT: should be a property list with the format: -- [#symbol: , -- #soundMembers: [: , ...], -- #transform: <3D transform or object>, -- #radius: <0 | positive float>, -- #zones: , -- #maxVolume: , -- #play: <#loop | #multiple | #once>}{, -- #priority: , -- -- #channel: ] -- The symbol property should already have been checked for -- uniqueness. If any of the other properties are invalid -- they will be replaced by default values. -- ACTION: Adopts the given parameters, or their defaults -------------------------------------------------------------------- aPropList = me.mCheckParameters(aPropList) pSymbol = aPropList.symbol plSoundMembers = aPropList.soundMembers pTransform = aPropList.transform pRadius = aPropList.radius plZones = aPropList.zones pMaxVolume = aPropList.maxVolume pPlayType = aPropList.play pPriority = aPropList.priority pChannel = 0 -- aPropList.channel -- Start the sound from the beginning pLastTime = 0 pSuspendTime = 0 if plSoundMembers.count() then pMember = plSoundMembers[1] end if return me end new on Finalize(me) ------------------------------------------------------ -- ACTION: Ensure that there are no circular references -------------------------------------------------------------------- end Finalize --==================================================================-- on __PUBLIC_COMMANDS__ end --==============================================================-- on Source_Start(me, aPropList) --------------------------------------- -- SOURCE: -- ACTION: Starts playing the sound from the beginning -- OUTPUT: Returns -------------------------------------------------------------------- if ilk(aPropList, #propList) then aPropList = me.mCheckParameters(aPropList) end if end Source_Start on Source_Stop(me) --------------------------------------------------- -- ACTION: Stops playing the sound -- OUTPUT: Returns -------------------------------------------------------------------- end Source_Stop on Source_Update(me) ------------------------------------------------- -- ACTION: Sets the pan and volume of the sound currently being -- played to match the current position and orientation of -- aTransform, which represents the player's ears. -- The values of pan and volume are those calculated for -- the most recent #Source_GetStatus call. -- OUTPUT: -- Returns the integer volume of the sound or an error -- symbol -- -------------------------------------------------------------------- if not pChannel then exit end if pChannel.pan = pPan pChannel.volume = pVolume end Source_Update on Source_Pause(me) -------------------------------------------------- -- ACTION: Stops the sound but remembers where it had got to. If -- followed by Source_Resume, the sound will carry on from -- its current position. -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- end Source_Pause on Source_Suspend(me, aSoundManager) --------------------------------- -- ACTION: The 3D Sound Manager has determined that this sound is -- currently too faint, but does not wish to stop it. -- This command remembers where the sound currently is and -- stops playing it. If followed by the Source_Resume, the -- sound will continue from the point where it would have -- got to if it had not been suspended. -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- if not pChannel then exit end if pLastTime = pChannel.currentTime if not pLastTime then -- The sound has finished playing case pPlayType of #unique: -- Never play this sound again aSoundManager.Sound_Delete(pSymbol) exit #once: -- Stop polling for the volume of this sound aSoundManager.Sound_Reserve(pSymbol, me) exit end case end if pSuspendTime = the milliseconds pChannel.fadeOut() -- over 1000 milliseconds pChannel = 0 end Source_Suspend on Source_Resume(me) ------------------------------------------------- -- ACTION: If the sound is paused, continues the sound from where it -- left off. If the sound is suspended, calculates where it -- would have got to and carries on from there. -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- if not pMember then return xCeption(#source, "No sound member for source #"&pSymbol) else if not pChannel then return xCeption(#source, "No channel for source #"&pSymbol) end if if pSuspendTime then vElapsed = (the milliseconds - pSuspendTime) / 1000.0 -- seconds vLastTime = pLastTime + vElapsed end if vList = [:] vList[#member] = pMember vList[#startTime] = pLastTime -- will be ignored by looping members pChannel.play(vList) end Source_Resume --==================================================================-- on __PUBLIC_METHODS__ end --==============================================================-- on Source_GetStatus(me, aTransform, aZone, aPropList) ---------------- -- INPUT: must be a transform. For performance -- purposes, no error checking is carried out. -- may be a symbol, in which case this sound is only -- played if aZone appears in plZones. -- may be a property list, in which case the -- entry [..., : pSymbol, ...] -- will be added to it. If this list is sorted, the loudest -- sound names will appear at the end. -- ACTION: Calculates the pan and volume of the sound currently -- being played to match the current position and -- orientation of aTransform, which represents the player's -- ears. The calling handler can then determine whether or -- not to play this sound through a chosen channel. -- * sets pVolume and pPan for later use with -- Source_Update() -- OUTPUT: Returns the volume at which this sound should be played -- or zero if it is out of earshot. -------------------------------------------------------------------- if not pMember then -- There is currently no member playing exit end if vVolume = 0 -- pre-emptively if symbolP(aZone) then if plZones.count then if not plZones.getPos(aZone) then -- The player is not in a zone where this sound can be heard vOutOfZone = TRUE end if end if end if if not vOutOfZone then if not pRadius then -- Sound has same volume everywhere vVolume = pMaxVolume else -- Calculate volume based on distance between source and player if pWorldSpace then vTransform = pTransform else -- We need to convert pTransform to worldSpace vTransform = pTransform.getWorldTransfrom() end if vVector = aTransform.position - vTransform.position vDistance = vVector.magnitude if vDistance < pRadius then -- This sound source is within earshot. Theoretically, the -- volume of the sound will decrease inversely as the square -- of the distance from the source: the power will be spread -- out over the surface of a sphere of the given radius. -- However, this implies infinite volume at a point source, -- which is obviously not achievable. In practice, no sound -- source is a point, since a sound is produced by a -- vibration, which is smeared over a volume of space. In -- practice, also sounds are reflected from walls, so the -- things are much more complex than theinverse square theory -- can account for. -- -- To simplify matters, we will used an approximation which -- says that the sound decreases in a linear manner as the -- distance increases. When the volume reaches zero, it -- remains at zero vVolume = (pRadius - vDistance) / pRadius * pMaxVolume -- Map the sound to the horizontal plane, if possible vVector = me.mMapToHorizontal(vVector, 0) if not vVector then -- The sound is directly above or directly below pPan = 0 else -- We'll use an approximation for the pan as well. Pan -- varies from -100 (full left) to +100 (full right), while -- the angle that the sound makes varies from 0 (in front) -- to 180¡ (behind). At 90¡ it is fully to one side. To -- determine which side needs a little crossProduct() -- mathematics. The pan will be identical whether the -- sound is in front or behind (assuming that the user has -- headphones on, not frontal speakers). -- Find a unit vector in the horizontal plane pointing in -- the same general directions as the player. If the -- player is looking vertically up or down, we will use -- the direction indicated by the top of his or her head. zAxis = aTransform.zAxis yAxis = aTransform.yAxis vAxis = me.mMapToHorizontal(zAxis, yAxis) vAngle = angleBetween(vVector, vAxis) -- 0 - 180 if not vAngle then pPan = 0 else if vAngle > 90 then -- The sound is behind the player, but its pan and -- volume will be symmetrical vAngle = 180 - vAngle end if pPan = vAngle * 10 / 9 -- 100 / 90¡ -- Which side is the sound on? vPerp = vVector.cross(vAxis) if vPerp.y < 0 then -- The sound is on the left pPan = -pPan end if -- vPerp.y < 0 end if -- not vAngle end if -- not vVector end if -- vDistance < pRadius end if -- pRadius = 0 end if -- not vOutOfZone if pVolume <> vVolume then -- The volume has changed pVolume = vVolume end if if ilk(aPropList, #propList) then aPropList.addProp(vVolume, pSymbol) end if return pVolume end Source_GetStatus on Source_GetState(me) ----------------------------------------------- -- OUTPUT: Returns 0 if there is no sound playing or a property list -- with the format: -- [#member: , -- #currentTime: , -- #endTime: ] -------------------------------------------------------------------- if not pMember then return 0 else if not pChannel then return 0 end if vCurrentTime = pChannel.currentTime if not vCurrentTime then return 0 end if vState = [:] vState[#member] = pMember vState[#currentTime] = pChannel.currentTime vState[#endTime] = pChannel.endTime return vState end Source_GetState on Source_SetMember(me, aSymbol, aSoundMember) ----------------------- -- INPUT: should be a symbol representing the sound name. -- It may not be #random. -- should be a sound member or 0 (for delete) -- ACTION: Replaces the existing sound member represented by aSymbol -- or adds a new one. If aSoundMember is not a sound -- member, any existing sound member represented by aSymbol -- will be deleted from plSoundMembers. -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- end Source_SetMember on Source_SetChannel(me, aChannelNumber) ----------------------------- -- INPUT: should be an integer from 0 - 8. -- ACTION: Sets the sound channel used to play this sound, and -- starts playing the sound if this is possible -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- if aChannelNumber then pChannel = sound(aChannelNumber) else pChannel = 0 end if end Source_SetChannel on Source_SetTransform(me, aTransform) ------------------------------- -- INPUT: should be 0, a point, a vector, aTransform or -- an object with a transform property -- ACTION: Sets pTransform to the given transform (or to a transform -- derived from the given input). May modify pWorldSpace. -- OUTPUT: Returns 0 for no error, or an error symbol if aTransform -- is not a valid input. -------------------------------------------------------------------- aTransform = me.mCheckTransform(aTransform, TRUE) if symbolP(aTransform) then -- Invalid input return aTransform end if pTransform = aTransform return 0 -- no error end Source_SetTransform on Source_SetRadius(me, aNumber) ------------------------------------- -- INPUT: should be a non-negative number -- ACTION: Sets pRadius to the given number -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- end Source_SetRadius on Source_AddZone(me, aSymbolOrList) --------------------------------- -- INPUT: should be a symbol or a list of symbol -- ACTION: Adds all unique valid symbols in aSymbolOrList to -- plZones. If aSymbolOrList is #all_zones then the -- plZones is set to an empty list, meaning the sound source -- is heard in all zones. -- OUTPUT: Returns 0 for no error, or an error symbol if -- aSymbolOrList is not a symbol or list of symbols -------------------------------------------------------------------- end Source_AddZone on Source_RemoveZone(me, aSymbolOrList) ------------------------------ -- INPUT: should be a symbol or a list of symbol -- ACTION: Deletes all symbols in aSymbolOrList from plZones. If -- aSymbolOrList is #all_zones then the plZones is -- set to an empty list, meaning the sound source is heard -- in all zones. -- OUTPUT: Returns 0 for no error, or an error symbol if -- aSymbolOrList is not a symbol or list of symbols -------------------------------------------------------------------- end Source_RemoveZone on Source_SetMaxVolume(me, anInteger) -------------------------------- -- INPUT: should be an integer 0 - 255 -- ACTION: Sets pMaxVolume to the given value, if it is valide -- OUTPUT: Returns 0 for no error, or an error symbol -------------------------------------------------------------------- end Source_SetMaxVolume on Source_Select(me, aSymbolOrMember) -------------------------------- -- INPUT: should be a sound member or its symbol -- identifier. It can also be a list of such items, in -- which case the sounds will be queued. Special cases are -- #random: selects a random sound -- #randomLoop creates a queue of random sounds -- ACTION: Prepares the given sound(s) for playback -------------------------------------------------------------------- end Source_Select on Source_SetPriority(me, anInteger) --------------------------------- -- INPUT: should be a non-negative integer -- ACTION: Sets pPriority to the given integer value -- OUTPUT: Returns 0 for no error or an error symbol -------------------------------------------------------------------- end Source_SetPriority --==================================================================-- on __PRIVATE_METHODS__ end --==============================================================-- on mCheckParameters(me, aPropList, aDontAdd) ------------------------- -- SOURCE: -- INPUT: should be a property list with the format: -- [#symbol: , -- #soundMembers: [: , ...], -- #transform: <3D transform or object>, -- #radius: <0 | positive float>, -- #zones: , -- #maxVolume: , -- #priority: , -- -- #channel: ] -- The #symbol property should already have been checked for -- uniqueness. If any of the other properties are invalid -- they will be replaced by default values. -- ACTION: Verifies that the correct values are provided, or -- replaces them with defaults. -- OUTPUT: Returns a property list with the expected format -------------------------------------------------------------------- if ilk(aPropList) <> #propList then aPropList = [:] end if -- Name vTemp = aPropList[#symbol] if not symbolP(vTemp) and not aDontAdd then aPropList[#symbol] = #untitled end if -- Sound Members List vTemp = aPropList[#soundMembers] if not voidP(vTemp) or not aDontAdd then case ilk(vTemp) of #propList: -- Create a duplicate list so that we can keep it encapsulated vTemp = vTemp.duplicate() -- Delete all invalid entries i = vTemp.count repeat while i vName = vTemp.getPropAt(i) vMember = vTemp[i] vError = FALSE -- Delete any entries which don't have the format: -- [..., : , ...] if ilk(vMember) <> #member then vError = TRUE else if vMember.type <> #sound then vError = TRUE else if not symbolP(vName) then vError = TRUE end if if vError then vTemp.deleteAt(i) end if i = i - 1 end repeat #member: -- Adopt this member as the default and only sound if vTemp.type = #sound then vTemp = [#default: vTemp] end if #string, #integer: -- Attempt to convert to a sound member vTemp = member(vTemp) if voidP(vTemp) then vTemp = [:] else if vTemp.type <> #sound then vTemp = [:] else vTemp = [#default: vTemp] end if otherwise vTemp = [:] end case aPropList[#soundMembers] = vTemp end if -- Transform vTemp = aPropList[#transform] if not voidP(vTemp) or not aDontAdd then vTemp = me.mCheckTransform(vTemp) aPropList[#transform] = vTemp end if -- Radius vTemp = aPropList[#radius] if not voidP(vTemp) or not aDontAdd then case ilk(vTemp) of #float, #integer: if not vTemp then vTemp = 0 -- sound is everywhere else vTemp = abs(vTemp) end if otherwise: vTemp = 0 end case aPropList[#radius] = vTemp end if -- Zones vTemp = aPropList[#zones] if not voidP(vTemp) or not aDontAdd then case ilk(vTemp) of #symbol: vTemp = list(vTemp) #list: vTemp = vTemp.duplicate() i = vTemp.count repeat while i vZone = vTemp[i] if not symbolP(vZone) then vTemp.deleteAt(i) end if i = i - 1 end repeat otherwise: vTemp = [] end case aPropList[#zones] = vTemp end if -- Maximum volume vTemp = aPropList[#maxVolume] if not voidP(vTemp) or not aDontAdd then if not integerP(vTemp) then aPropList[#maxVolume] = 255 else aPropList[#maxVolume] = max(0, min(vTemp, 255)) end if end if -- Play type vTemp = aPropList[#play] if symbolP(vTemp) or not aDontAdd then case vTemp of #loop: -- Sounds will loop #unique: -- Sound will be purged once it has been played #once: -- Sound will be played once then kept in reserve otherwise: vTemp = #multiple -- Sound will be kept in memory so that it can be played again end case aPropList[#play] = vTemp end if -- Priority vTemp = aPropList[#priority] if not voidP(vTemp) or not aDontAdd then if not integerP(vTemp) then aPropList[#priority] = 0 else if vTemp < 0 then aPropList.priority = 0 end if end if return aPropList end mCheckParameters on mCheckTransform(me, aTransform, aStrict) -------------------------- -- SOURCE: Called by Source_SetTransform() and mCheckParameters() -- INPUT: can be any Lingo value, but 3D objects, -- transforms, vectors, 2D points and 0 are preferred -- will be TRUE if the call comes from -- Source_SetTransform(), FALSE if not. -- ACTION: Converts the input to a transform if necessary -- OUTPUT: Returns a transform or a 3D object with a transform, -- or if aStrict is TRUE, an error symbol if aTransform is -- not one of the preferred types. -------------------------------------------------------------------- vWorldSpace = TRUE case ilk(aTransform) of #camera, #group, #light, #model: -- Dynamic. Retain pointer to the object. The object may be -- or become the child of another, and we need to be able to -- calculate its position and orientation in worldSpace. vWorldSpace = FALSE #transform: -- Dynamic. Retain original pointer. Assume that the transform -- is in worldSpace and will remain so. #point: -- Static vVector = vector(aTransform.locH,0,aTransform.locV) aTransform = transform() aTransform.position = vVector #vector: -- Static vVector = aTransform aTransform = transform() aTransform.position = vVector otherwise: -- Static at the centre of the world if not aStrict then aTransform = transform() else if integerP(aTransform) then if not aTransform then -- aTransform is explicitly 0 aTransform = transform() else aTransform = #zeroExpected end if else aTransform = #transformExpected end if end case if not symbolP(aTransform) then -- No error occurred; we can adopt this transform pWorldSpace = vWorldSpace end if return aTransform end mCheckTransform on mMapToHorizontal(me, aVector, aFallback) -------------------------- -- INPUT: will be a direction vector -- may be a direction vector orthogonal to -- aVector, or it may be 0 -- ACTION: Maps aVector onto the horizontal plane, if possible, -- and normalizes it -- OUTPUT: Returns a normalized vector whose y component is 0, or -- aFallback -------------------------------------------------------------------- yAxis = vector(0, 1, 0) vPerp = aVector.cross(yAxis) if vPerp.magnitude = 0 then -- aVector is vertically up or down. Use aFallback instead aVector = aFallback else -- Map aVector onto the plane where y = 0 aVector = yAxis.cross(vPerp).getNormalized() end if return aVector end mMapToHorizontal